Compare commits

..

28 Commits

Author SHA1 Message Date
Tulir Asokan 95920728f4 Bump version to 0.9.0 2020-11-17 18:01:14 +02:00
Tulir Asokan e85be95d2d Fix cleaning unidentified rooms. Fixes #541 2020-11-17 18:01:06 +02:00
Tulir Asokan 3006b3ab3b Update mautrix-python 2020-11-17 17:57:29 +02:00
Tulir Asokan d4d6cfa87d Bump version to 0.9.0rc3 2020-11-12 01:41:44 +02:00
Tulir Asokan b8cfcbe5ee Set nova nightly image hash in CI 2020-11-11 23:19:19 +02:00
Tulir Asokan 9875833c90 Use correct relation type for replies 2020-11-10 12:31:03 +02:00
Tulir Asokan 38d94484bb Use mautrix utility function for file upload retry 2020-11-10 00:21:36 +02:00
Tulir Asokan 0b3014ff88 Retry sending messages if server returns 502 2020-11-09 21:01:30 +02:00
Tulir Asokan 04c64949e7 Update mautrix-python 2020-11-07 16:01:38 +02:00
Tulir Asokan be59d50678 Fix Matrix->Telegram name mentions 2020-11-07 16:01:21 +02:00
Tulir Asokan 04e2497dd3 Bump version to 0.9.0rc2 2020-11-06 21:30:07 +02:00
Tulir Asokan 2c59cb4871 Fix sending plaintext captions to Telegram 2020-11-06 18:14:20 +02:00
Tulir Asokan 64ddd07171 Update mautrix-python 2020-11-05 22:19:09 +02:00
Tulir Asokan 1b91fbc806 Check room encryption status when bridging portal 2020-10-30 20:16:02 +02:00
Tulir Asokan 2b6cffc8ef Fix bugs in manual bridging that were added by the previous fix 2020-10-30 19:55:43 +02:00
Tulir Asokan 5cc0afef85 Let mautrix-python handle generating namespaces for the registration 2020-10-30 19:46:37 +02:00
Tulir Asokan 52adbb7335 Fix potential bugs in manual bridging 2020-10-30 19:46:02 +02:00
Tulir Asokan dd3bdd2846 Allow unbridging direct chat portals. Fixes #495 2020-10-29 23:02:37 +02:00
Tulir Asokan f088599dec Disconnect from Telegram after logging out 2020-10-29 22:38:54 +02:00
Tulir Asokan fe573865aa Completely delete private chat portals when user logs out
If it just kicks the user, logging in again later would cause the
bridge to think there's a portal, but fail to invite the user again.

Fixes #397
2020-10-29 22:33:22 +02:00
Tulir Asokan 5316ed57af Send link to Telegram ToS when signing up 2020-10-28 18:54:12 +02:00
Tulir Asokan 1567239ae6 Update connection metric after logging in 2020-10-28 18:44:50 +02:00
Tulir Asokan 24c65f8942 Don't set bridge_connected metric for non-logged-in users 2020-10-28 18:14:12 +02:00
Tulir Asokan 213e63830d Update mautrix-python and unpin yarl/aiohttp 2020-10-28 12:34:11 +02:00
Tulir Asokan efe532e4d0 Don't check user database when handling ephemeral events 2020-10-27 16:49:54 +02:00
Tulir Asokan 8392f46db9 Fix bugs in left member check 2020-10-27 15:37:38 +02:00
Tulir Asokan 87cacc9b20 Update mautrix-python 2020-10-27 15:19:19 +02:00
Tulir Asokan d808893274 Move clean-rooms command to mautrix-python 2020-10-26 19:56:20 +02:00
21 changed files with 256 additions and 457 deletions
+7
View File
@@ -17,6 +17,13 @@ build amd64:
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --build-arg TARGETARCH=amd64 --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 . - docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --build-arg TARGETARCH=amd64 --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 - docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
after_script:
- |
if [ "$CI_COMMIT_BRANCH" = "master" ]; then
apk add --update curl
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"}'
fi
build arm64: build arm64:
stage: build stage: build
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.9.0rc1" __version__ = "0.9.0"
__author__ = "Tulir Asokan <tulir@maunium.net>" __author__ = "Tulir Asokan <tulir@maunium.net>"
+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, clean_rooms, matrix_auth, manhole from . import portal, telegram, matrix_auth, manhole
__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",
-195
View File
@@ -1,195 +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 List, NamedTuple, Tuple, Union
from mautrix.appservice import IntentAPI
from mautrix.errors import MatrixRequestError
from mautrix.types import RoomID, UserID, EventID, EventType
from . import command_handler, CommandEvent, SECTION_ADMIN
from .. import puppet as pu, portal as po
ManagementRoom = NamedTuple('ManagementRoom', room_id=RoomID, user_id=UserID)
async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[RoomID], List[RoomID],
List['po.Portal'], List['po.Portal']]:
management_rooms: List[ManagementRoom] = []
unidentified_rooms: List[RoomID] = []
tombstoned_rooms: List[RoomID] = []
portals: List[po.Portal] = []
empty_portals: List[po.Portal] = []
rooms = await intent.get_joined_rooms()
for room_id in rooms:
portal = po.Portal.get_by_mxid(room_id)
if not portal:
try:
tombstone = await intent.get_state_event(room_id, EventType.ROOM_TOMBSTONE)
if tombstone and tombstone.replacement_room:
tombstoned_rooms.append(room_id)
continue
except MatrixRequestError:
pass
try:
members = await intent.get_room_members(room_id)
except MatrixRequestError:
members = []
if len(members) == 2:
other_member = members[0] if members[0] != intent.mxid else members[1]
if pu.Puppet.get_id_from_mxid(other_member):
unidentified_rooms.append(room_id)
else:
management_rooms.append(ManagementRoom(room_id, other_member))
else:
unidentified_rooms.append(room_id)
else:
members = await portal.get_authenticated_matrix_users()
if len(members) == 0:
empty_portals.append(portal)
else:
portals.append(portal)
return management_rooms, unidentified_rooms, tombstoned_rooms, portals, empty_portals
@command_handler(needs_admin=True, needs_auth=False, management_only=True, name="clean-rooms",
help_section=SECTION_ADMIN,
help_text="Clean up unused portal/management rooms.")
async def clean_rooms(evt: CommandEvent) -> EventID:
(management_rooms, unidentified_rooms, tombstoned_rooms,
portals, empty_portals) = await _find_rooms(evt.az.intent)
reply = ["#### Management rooms (M)"]
reply += ([f"{n+1}. [M{n+1}](https://matrix.to/#/{room}) (with {other_member}"
for n, (room, other_member) in enumerate(management_rooms)]
or ["No management rooms found."])
reply.append("#### Active portal rooms (A)")
reply += ([f"{n+1}. [A{n+1}](https://matrix.to/#/{portal.mxid}) "
f"(to Telegram chat \"{portal.title}\")"
for n, portal in enumerate(portals)]
or ["No active portal rooms found."])
reply.append("#### Unidentified rooms (U)")
reply += ([f"{n+1}. [U{n+1}](https://matrix.to/#/{room})"
for n, room in enumerate(unidentified_rooms)]
or ["No unidentified rooms found."])
reply.append("#### Tombstoned rooms (T)")
reply += ([f"{n+1}. [T{n+1}](https://matrix.to/#/{room})"
for n, room in enumerate(tombstoned_rooms)]
or ["No tombstoned rooms found."])
reply.append("#### Inactive portal rooms (I)")
reply += ([f"{n}. [I{n}](https://matrix.to/#/{portal.mxid}) "
f"(to Telegram chat \"{portal.title}\")"
for n, portal in enumerate(empty_portals)]
or ["No inactive portal rooms found."])
reply += ["#### Usage",
("To clean the recommended set of rooms (unidentified & inactive portals), "
"type `$cmdprefix+sp clean-recommended`"),
"",
("To clean other groups of rooms, type `$cmdprefix+sp clean-groups <letters>` "
"where `letters` are the first letters of the group names (M, A, U, I, T)"),
"",
("To clean specific rooms, type `$cmdprefix+sp clean-range <range>` "
"where `range` is the range (e.g. `5-21`) prefixed with the first letter of"
"the group name. (e.g. `I2-6`)"),
"",
("Please note that you will have to re-run `$cmdprefix+sp clean-rooms` "
"between each use of the commands above.")]
evt.sender.command_status = {
"next": lambda clean_evt: set_rooms_to_clean(clean_evt, management_rooms,
unidentified_rooms, tombstoned_rooms, portals,
empty_portals),
"action": "Room cleaning",
}
return await evt.reply("\n".join(reply))
async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
unidentified_rooms: List[RoomID], tombstoned_rooms: List[RoomID],
portals: List["po.Portal"], empty_portals: List["po.Portal"]) -> None:
command = evt.args[0]
rooms_to_clean: List[Union[po.Portal, RoomID]] = []
if command == "clean-recommended":
rooms_to_clean += empty_portals
rooms_to_clean += unidentified_rooms
elif command == "clean-groups":
if len(evt.args) < 2:
return await evt.reply("**Usage:** `$cmdprefix+sp clean-groups [M][A][U][I]")
groups_to_clean = evt.args[1].upper()
if "M" in groups_to_clean:
rooms_to_clean += [room_id for (room_id, user_id) in management_rooms]
if "A" in groups_to_clean:
rooms_to_clean += portals
if "U" in groups_to_clean:
rooms_to_clean += unidentified_rooms
if "I" in groups_to_clean:
rooms_to_clean += empty_portals
if "T" in groups_to_clean:
rooms_to_clean += tombstoned_rooms
elif command == "clean-range":
try:
clean_range = evt.args[1]
group, clean_range = clean_range[0], clean_range[1:]
start, end = clean_range.split("-")
start, end = int(start), int(end)
if group == "M":
group = [room_id for (room_id, user_id) in management_rooms]
elif group == "A":
group = portals
elif group == "U":
group = unidentified_rooms
elif group == "I":
group = empty_portals
elif group == "T":
group = tombstoned_rooms
else:
raise ValueError("Unknown group")
rooms_to_clean = group[start - 1:end]
except (KeyError, ValueError):
return await evt.reply(
"**Usage:** `$cmdprefix+sp clean-groups <_M|A|U|I_><range>")
else:
return await evt.reply(f"Unknown room cleaning action `{command}`. "
"Use `$cmdprefix+sp cancel` to cancel room "
"cleaning.")
evt.sender.command_status = {
"next": lambda confirm: execute_room_cleanup(confirm, rooms_to_clean),
"action": "Room cleaning",
}
await evt.reply(f"To confirm cleaning up {len(rooms_to_clean)} rooms, type "
"`$cmdprefix+sp confirm-clean`.")
async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, RoomID]]) -> None:
if len(evt.args) > 0 and evt.args[0] == "confirm-clean":
await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. "
"This might take a while.")
cleaned = 0
for room in rooms_to_clean:
if isinstance(room, po.Portal):
await room.cleanup_and_delete()
cleaned += 1
else:
await po.Portal.cleanup_room(evt.az.intent, room, "Room deleted")
cleaned += 1
evt.sender.command_status = None
await evt.reply(f"{cleaned} rooms cleaned up successfully.")
else:
await evt.reply("Room cleaning cancelled.")
+19 -11
View File
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Tuple, Coroutine from typing import Optional, Tuple, Awaitable
import asyncio import asyncio
from telethon.tl.types import ChatForbidden, ChannelForbidden from telethon.tl.types import ChatForbidden, ChannelForbidden
@@ -105,18 +105,17 @@ async def bridge(evt: CommandEvent) -> EventID:
async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal" async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal"
) -> Tuple[ ) -> Tuple[bool, Optional[Awaitable[None]]]:
bool, Optional[Coroutine[None, None, None]]]:
if not portal.mxid: if not portal.mxid:
await evt.reply("The portal seems to have lost its Matrix room between you" await evt.reply("The portal seems to have lost its Matrix room between you"
"calling `$cmdprefix+sp bridge` and this command.\n\n" "calling `$cmdprefix+sp bridge` and this command.\n\n"
"Continuing without touching previous Matrix room...") "Continuing without touching previous Matrix room...")
return True, None return True, None
elif evt.args[0] == "delete-and-continue": elif evt.args[0] == "delete-and-continue":
return True, portal.cleanup_portal("Portal deleted (moving to another room)") return True, portal.cleanup_portal("Portal deleted (moving to another room)", delete=False)
elif evt.args[0] == "unbridge-and-continue": elif evt.args[0] == "unbridge-and-continue":
return True, portal.cleanup_portal("Room unbridged (portal moving to another room)", return True, portal.cleanup_portal("Room unbridged (portal moving to another room)",
puppets_only=True) puppets_only=True, delete=False)
else: else:
await evt.reply( await evt.reply(
"The chat you were trying to bridge already has a Matrix portal room.\n\n" "The chat you were trying to bridge already has a Matrix portal room.\n\n"
@@ -137,6 +136,9 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[EventID]:
return await evt.reply("Fatal error: tgid or peer_type missing from command_status. " return await evt.reply("Fatal error: tgid or peer_type missing from command_status. "
"This shouldn't happen unless you're messing with the command " "This shouldn't happen unless you're messing with the command "
"handler code.") "handler code.")
is_logged_in = await evt.sender.is_logged_in() and not status["force_use_bot"]
if "mxid" in status: if "mxid" in status:
ok, coro = await cleanup_old_portal_while_bridging(evt, portal) ok, coro = await cleanup_old_portal_while_bridging(evt, portal)
if not ok: if not ok:
@@ -154,7 +156,13 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[EventID]:
"`$cmdprefix+sp cancel` to cancel.") "`$cmdprefix+sp cancel` to cancel.")
evt.sender.command_status = None evt.sender.command_status = None
is_logged_in = await evt.sender.is_logged_in() and not status["force_use_bot"] async with portal._room_create_lock:
await _locked_confirm_bridge(evt, portal=portal, room_id=bridge_to_mxid,
is_logged_in=is_logged_in)
async def _locked_confirm_bridge(evt: CommandEvent, portal: 'po.Portal', room_id: RoomID,
is_logged_in: bool) -> Optional[EventID]:
user = evt.sender if is_logged_in else evt.tgbot user = evt.sender if is_logged_in else evt.tgbot
try: try:
entity = await user.client.get_entity(portal.peer) entity = await user.client.get_entity(portal.peer)
@@ -172,14 +180,14 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[EventID]:
else: else:
return await evt.reply("The bot doesn't seem to be in that chat.") return await evt.reply("The bot doesn't seem to be in that chat.")
direct = False portal.mxid = room_id
portal.by_mxid[portal.mxid] = portal
portal.mxid = bridge_to_mxid (portal.title, portal.about, levels,
portal.title, portal.about, levels = await get_initial_state(evt.az.intent, evt.room_id) portal.encrypted) = await get_initial_state(evt.az.intent, evt.room_id)
portal.photo_id = "" portal.photo_id = ""
await portal.save() await portal.save()
asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels), asyncio.ensure_future(portal.update_matrix_room(user, entity, direct=False, levels=levels),
loop=evt.loop) loop=evt.loop)
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.") return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
@@ -38,7 +38,7 @@ async def create(evt: CommandEvent) -> EventID:
if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"): if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply("You do not have the permissions to bridge this room.") return await evt.reply("You do not have the permissions to bridge this room.")
title, about, levels = await get_initial_state(evt.az.intent, evt.room_id) title, about, levels, encrypted = await get_initial_state(evt.az.intent, evt.room_id)
if not title: if not title:
return await evt.reply("Please set a title before creating a Telegram chat.") return await evt.reply("Please set a title before creating a Telegram chat.")
@@ -50,11 +50,11 @@ async def create(evt: CommandEvent) -> EventID:
"group": "chat", "group": "chat",
}[type] }[type]
portal = po.Portal(tgid=TelegramID(0), peer_type=type, portal = po.Portal(tgid=TelegramID(0), peer_type=type, mxid=evt.room_id,
mxid=evt.room_id, title=title, about=about) title=title, about=about, encrypted=encrypted)
try: try:
await portal.create_telegram_chat(evt.sender, supergroup=supergroup) await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
except ValueError as e: except ValueError as e:
portal.delete() await portal.delete()
return await evt.reply(e.args[0]) return await evt.reply(e.args[0])
return await evt.reply(f"Telegram chat created. ID: {portal.tgid}") return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
@@ -31,6 +31,12 @@ async def _get_portal_and_check_permission(evt: CommandEvent) -> Optional[po.Por
await evt.reply(f"{that_this} is not a portal room.") await evt.reply(f"{that_this} is not a portal room.")
return None return None
if portal.peer_type == "user":
if portal.tg_receiver != evt.sender.tgid:
await evt.reply("You do not have the permissions to unbridge that portal.")
return None
return portal
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"): if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
await evt.reply("You do not have the permissions to unbridge that portal.") await evt.reply("You do not have the permissions to unbridge that portal.")
return None return None
+5 -2
View File
@@ -25,11 +25,12 @@ OptStr = Optional[str]
async def get_initial_state(intent: IntentAPI, room_id: RoomID async def get_initial_state(intent: IntentAPI, room_id: RoomID
) -> Tuple[OptStr, OptStr, Optional[PowerLevelStateEventContent]]: ) -> 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
levels: Optional[PowerLevelStateEventContent] = None levels: Optional[PowerLevelStateEventContent] = None
encrypted: bool = False
for event in state: for event in state:
try: try:
if event.type == EventType.ROOM_NAME: if event.type == EventType.ROOM_NAME:
@@ -40,10 +41,12 @@ async def get_initial_state(intent: IntentAPI, room_id: RoomID
levels = event.content levels = event.content
elif event.type == EventType.ROOM_CANONICAL_ALIAS: elif event.type == EventType.ROOM_CANONICAL_ALIAS:
title = title or event.content.canonical_alias title = title or event.content.canonical_alias
elif event.type == EventType.ROOM_ENCRYPTION:
encrypted = True
except KeyError: except KeyError:
# Some state event probably has empty content # Some state event probably has empty content
pass pass
return title, about, levels return title, about, levels, encrypted
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,
+12 -14
View File
@@ -31,7 +31,7 @@ from mautrix.types import (EventID, UserID, MediaMessageEventContent, ImageInfo,
from ... import user as u from ... import user as u
from ...types import TelegramID from ...types import TelegramID
from ...commands import command_handler, CommandEvent, SECTION_AUTH from ...commands import command_handler, CommandEvent, SECTION_AUTH
from ...util import format_duration from ...util import format_duration as fmt_duration
try: try:
import qrcode import qrcode
@@ -70,7 +70,7 @@ async def ping_bot(evt: CommandEvent) -> EventID:
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_args="<_phone_> <_full name_>", help_args="<_phone_> <_full name_>",
help_text="Register to Telegram") help_text="Register to Telegram")
async def register(evt: CommandEvent) -> Optional[EventID]: async def register(evt: CommandEvent) -> EventID:
if await evt.sender.is_logged_in(): if await evt.sender.is_logged_in():
return await evt.reply("You are already logged in.") return await evt.reply("You are already logged in.")
elif len(evt.args) < 1: elif len(evt.args) < 1:
@@ -87,7 +87,8 @@ async def register(evt: CommandEvent) -> Optional[EventID]:
"action": "Register", "action": "Register",
"full_name": full_name, "full_name": full_name,
}) })
return None return await evt.reply("By signing up for Telegram, you agree to "
"the terms of service: https://telegram.org/tos")
async def enter_code_register(evt: CommandEvent) -> EventID: async def enter_code_register(evt: CommandEvent) -> EventID:
@@ -222,21 +223,18 @@ async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[
ok = True ok = True
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.") return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
except PhoneNumberAppSignupForbiddenError: except PhoneNumberAppSignupForbiddenError:
return await evt.reply( return await evt.reply("Your phone number does not allow 3rd party apps to sign in.")
"Your phone number does not allow 3rd party apps to sign in.")
except PhoneNumberFloodError: except PhoneNumberFloodError:
return await evt.reply( return await evt.reply("Your phone number has been temporarily blocked for flooding. "
"Your phone number has been temporarily blocked for flooding. " "The ban is usually applied for around a day.")
"The ban is usually applied for around a day.")
except FloodWaitError as e: except FloodWaitError as e:
return await evt.reply( return await evt.reply("Your phone number has been temporarily blocked for flooding. "
"Your phone number has been temporarily blocked for flooding. " f"Please wait for {fmt_duration(e.seconds)} before trying again.")
f"Please wait for {format_duration(e.seconds)} before trying again.")
except PhoneNumberBannedError: except PhoneNumberBannedError:
return await evt.reply("Your phone number has been banned from Telegram.") return await evt.reply("Your phone number has been banned from Telegram.")
except PhoneNumberUnoccupiedError: except PhoneNumberUnoccupiedError:
return await evt.reply("That phone number has not been registered. " return await evt.reply("That phone number has not been registered. "
"Please register with `$cmdprefix+sp register <phone>`.") "Please register with `$cmdprefix+sp register <phone>`.")
except PhoneNumberInvalidError: except PhoneNumberInvalidError:
return await evt.reply("That phone number is not valid.") return await evt.reply("That phone number is not valid.")
except Exception: except Exception:
+3 -24
View File
@@ -13,14 +13,14 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Any, Dict, List, NamedTuple from typing import Any, List, NamedTuple
from ruamel.yaml.comments import CommentedMap from ruamel.yaml.comments import CommentedMap
import os import os
from mautrix.types import UserID from mautrix.types import UserID
from mautrix.client import Client from mautrix.client import Client
from mautrix.bridge.config import (BaseBridgeConfig, ConfigUpdateHelper, ForbiddenKey, from mautrix.bridge.config import BaseBridgeConfig
ForbiddenDefault) from mautrix.util.config import ForbiddenKey, ForbiddenDefault, ConfigUpdateHelper
Permissions = NamedTuple("Permissions", relaybot=bool, user=bool, puppeting=bool, Permissions = NamedTuple("Permissions", relaybot=bool, user=bool, puppeting=bool,
matrix_puppeting=bool, admin=bool, level=str) matrix_puppeting=bool, admin=bool, level=str)
@@ -240,24 +240,3 @@ class Config(BaseBridgeConfig):
return self._get_permissions(homeserver) return self._get_permissions(homeserver)
return self._get_permissions("*") return self._get_permissions("*")
@property
def namespaces(self) -> Dict[str, List[Dict[str, Any]]]:
homeserver = self["homeserver.domain"]
username_format = self["bridge.username_template"].format(userid=".+")
alias_format = self["bridge.alias_template"].format(groupname=".+")
group_id = ({"group_id": self["appservice.community_id"]}
if self["appservice.community_id"] else {})
return {
"users": [{
"exclusive": True,
"regex": f"@{username_format}:{homeserver}",
**group_id,
}],
"aliases": [{
"exclusive": True,
"regex": f"#{alias_format}:{homeserver}",
}]
}
+1 -2
View File
@@ -1,5 +1,4 @@
from .from_matrix import (matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram, from .from_matrix import matrix_reply_to_telegram, matrix_to_telegram, init_mx
init_mx)
from .from_telegram import telegram_reply_to_matrix, telegram_to_matrix from .from_telegram import telegram_reply_to_matrix, telegram_to_matrix
from .. import context as c from .. import context as c
@@ -18,10 +18,12 @@ import re
import logging import logging
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic, from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic,
TypeMessageEntity) TypeMessageEntity, InputMessageEntityMentionName)
from telethon.helpers import add_surrogate, del_surrogate from telethon.helpers import add_surrogate, del_surrogate
from telethon import TelegramClient
from mautrix.types import RoomID, MessageEventContent from mautrix.types import RoomID, MessageEventContent
from mautrix.util.logging import TraceLogger
from ... import puppet as pu from ... import puppet as pu
from ...types import TelegramID from ...types import TelegramID
@@ -31,30 +33,19 @@ from .parser import ParsedMessage, parse_html
if TYPE_CHECKING: if TYPE_CHECKING:
from ...context import Context from ...context import Context
log: logging.Logger = logging.getLogger("mau.fmt.mx") log: TraceLogger = logging.getLogger("mau.fmt.mx")
should_bridge_plaintext_highlights: bool = False should_bridge_plaintext_highlights: bool = False
command_regex: Pattern = re.compile(r"^!([A-Za-z0-9@]+)") command_regex: Pattern = re.compile(r"^!([A-Za-z0-9@]+)")
not_command_regex: Pattern = re.compile(r"^\\(![A-Za-z0-9@]+)") not_command_regex: Pattern = re.compile(r"^\\(![A-Za-z0-9@]+)")
plain_mention_regex: Optional[Pattern] = None plain_mention_regex: Optional[Pattern] = None
def plain_mention_to_html(match: Match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet:
return (f"{match.group(1)}"
f"<a href='https://matrix.to/#/{puppet.mxid}'>"
f"{puppet.displayname}"
"</a>")
return "".join(match.groups())
MAX_LENGTH = 4096 MAX_LENGTH = 4096
CUTOFF_TEXT = " [message cut]" CUTOFF_TEXT = " [message cut]"
CUT_MAX_LENGTH = MAX_LENGTH - len(CUTOFF_TEXT) CUT_MAX_LENGTH = MAX_LENGTH - len(CUTOFF_TEXT)
def cut_long_message(message: str, entities: List[TypeMessageEntity]) -> ParsedMessage: def _cut_long_message(message: str, entities: List[TypeMessageEntity]) -> ParsedMessage:
if len(message) > MAX_LENGTH: if len(message) > MAX_LENGTH:
message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT
new_entities = [] new_entities = []
@@ -73,23 +64,6 @@ class FormatError(Exception):
pass pass
def matrix_to_telegram(html: str) -> ParsedMessage:
try:
html = command_regex.sub(r"<command>\1</command>", html)
html = html.replace("\t", " " * 4)
html = not_command_regex.sub(r"\1", html)
if should_bridge_plaintext_highlights:
html = plain_mention_regex.sub(plain_mention_to_html, html)
text, entities = parse_html(add_surrogate(html))
text = del_surrogate(text.strip())
text, entities = cut_long_message(text, entities)
return text, entities
except Exception as e:
raise FormatError(f"Failed to convert Matrix format: {html}") from e
def matrix_reply_to_telegram(content: MessageEventContent, tg_space: TelegramID, def matrix_reply_to_telegram(content: MessageEventContent, tg_space: TelegramID,
room_id: Optional[RoomID] = None) -> Optional[TelegramID]: room_id: Optional[RoomID] = None) -> Optional[TelegramID]:
event_id = content.get_reply_to() event_id = content.get_reply_to()
@@ -103,19 +77,61 @@ def matrix_reply_to_telegram(content: MessageEventContent, tg_space: TelegramID,
return None return None
def matrix_text_to_telegram(text: str) -> ParsedMessage: async def matrix_to_telegram(client: TelegramClient, *, text: Optional[str] = None,
html: Optional[str] = None) -> ParsedMessage:
if html is not None:
text, entities = _matrix_html_to_telegram(html)
elif text is not None:
text, entities = _matrix_text_to_telegram(text)
else:
raise ValueError("text or html must be provided to convert formatting")
await _fix_name_mentions(client, entities)
return text, entities
def _matrix_html_to_telegram(html: str) -> ParsedMessage:
try:
html = command_regex.sub(r"<command>\1</command>", html)
html = html.replace("\t", " " * 4)
html = not_command_regex.sub(r"\1", html)
if should_bridge_plaintext_highlights:
html = plain_mention_regex.sub(_plain_mention_to_html, html)
text, entities = parse_html(add_surrogate(html))
text = del_surrogate(text.strip())
text, entities = _cut_long_message(text, entities)
return text, entities
except Exception as e:
raise FormatError(f"Failed to convert Matrix format: {html}") from e
def _matrix_text_to_telegram(text: str) -> ParsedMessage:
text = command_regex.sub(r"/\1", text) text = command_regex.sub(r"/\1", text)
text = text.replace("\t", " " * 4) text = text.replace("\t", " " * 4)
text = not_command_regex.sub(r"\1", text) text = not_command_regex.sub(r"\1", text)
if should_bridge_plaintext_highlights: if should_bridge_plaintext_highlights:
entities, pmr_replacer = plain_mention_to_text() entities, pmr_replacer = _plain_mention_to_text()
text = plain_mention_regex.sub(pmr_replacer, text) text = plain_mention_regex.sub(pmr_replacer, text)
else: else:
entities = [] entities = []
return text, entities return text, entities
def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[Match], str]]: async def _fix_name_mentions(client: TelegramClient, entities: List[TypeMessageEntity]) -> None:
for index in reversed(range(len(entities))):
entity = entities[index]
if isinstance(entity, (MessageEntityMentionName, InputMessageEntityMentionName)):
try:
user = await client.get_input_entity(entity.user_id)
except (ValueError, TypeError) as e:
log.trace(f"Dropping mention of {entity.user_id}: {e}")
del entities[index]
else:
entities[index] = InputMessageEntityMentionName(entity.offset, entity.length, user)
def _plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[Match], str]]:
entities = [] entities = []
def replacer(match: Match) -> str: def replacer(match: Match) -> str:
@@ -136,6 +152,16 @@ def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[Match],
return entities, replacer return entities, replacer
def _plain_mention_to_html(match: Match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet:
return (f"{match.group(1)}"
f"<a href='https://matrix.to/#/{puppet.mxid}'>"
f"{puppet.displayname}"
"</a>")
return "".join(match.groups())
def init_mx(context: "Context") -> None: def init_mx(context: "Context") -> None:
global plain_mention_regex, should_bridge_plaintext_highlights global plain_mention_regex, should_bridge_plaintext_highlights
config = context.config config = context.config
+2 -2
View File
@@ -51,7 +51,7 @@ def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Optional[R
else source.tgid) else source.tgid)
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space) msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space)
if msg: if msg:
return RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid) return RelatesTo(rel_type=RelationType.REPLY, event_id=msg.mxid)
return None return None
@@ -126,7 +126,7 @@ async def _add_reply_header(source: 'AbstractUser', content: TextMessageEventCon
if not msg: if not msg:
return return
content.relates_to = RelatesTo(rel_type=RelationType.REFERENCE, 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: MessageEvent = await main_intent.get_event(msg.mx_room, msg.mxid)
+9 -13
View File
@@ -328,17 +328,15 @@ class MatrixHandler(BaseMatrixHandler):
return return
for user_id, event_id in receipts: for user_id, event_id in receipts:
user = await u.User.get_by_mxid(user_id).ensure_started() user = u.User.get_by_mxid(user_id, check_db=False, create=False)
if not await user.is_logged_in(): if user and await user.is_logged_in():
continue await portal.mark_read(user, event_id)
await portal.mark_read(user, event_id)
@staticmethod @staticmethod
async def handle_presence(user_id: UserID, presence: PresenceState) -> None: async def handle_presence(user_id: UserID, presence: PresenceState) -> None:
user = await u.User.get_by_mxid(user_id).ensure_started() user = u.User.get_by_mxid(user_id, check_db=False, create=False)
if not await user.is_logged_in(): if user and await user.is_logged_in():
return await user.set_presence(presence == PresenceState.ONLINE)
await user.set_presence(presence == PresenceState.ONLINE)
async def handle_typing(self, room_id: RoomID, now_typing: Set[UserID]) -> None: async def handle_typing(self, room_id: RoomID, now_typing: Set[UserID]) -> None:
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
@@ -353,11 +351,9 @@ class MatrixHandler(BaseMatrixHandler):
if is_typing and was_typing: if is_typing and was_typing:
continue continue
user = await u.User.get_by_mxid(user_id).ensure_started() user = u.User.get_by_mxid(user_id, check_db=False, create=False)
if not await user.is_logged_in(): if user and await user.is_logged_in():
continue await portal.set_typing(user, is_typing)
await portal.set_typing(user, is_typing)
self.previously_typing[room_id] = now_typing self.previously_typing[room_id] = now_typing
+19 -39
View File
@@ -107,6 +107,7 @@ class BasePortal(MautrixBasePortal, ABC):
_db_instance: DBPortal _db_instance: DBPortal
_main_intent: Optional[IntentAPI] _main_intent: Optional[IntentAPI]
_room_create_lock: asyncio.Lock
def __init__(self, tgid: TelegramID, peer_type: str, tg_receiver: Optional[TelegramID] = None, def __init__(self, tgid: TelegramID, peer_type: str, tg_receiver: Optional[TelegramID] = None,
mxid: Optional[RoomID] = None, username: Optional[str] = None, mxid: Optional[RoomID] = None, username: Optional[str] = None,
@@ -155,6 +156,10 @@ class BasePortal(MautrixBasePortal, ABC):
return str(self.tgid) return str(self.tgid)
return f"{self.tg_receiver}<->{self.tgid}" return f"{self.tg_receiver}<->{self.tgid}"
@property
def name(self) -> str:
return self.title
@property @property
def alias(self) -> Optional[RoomAlias]: def alias(self) -> Optional[RoomAlias]:
if not self.username: if not self.username:
@@ -272,61 +277,32 @@ class BasePortal(MautrixBasePortal, ABC):
# endregion # endregion
# region Matrix room cleanup # region Matrix room cleanup
async def get_authenticated_matrix_users(self) -> List['u.User']: async def get_authenticated_matrix_users(self) -> List[UserID]:
try: try:
members = await self.main_intent.get_room_members(self.mxid) members = await self.main_intent.get_room_members(self.mxid)
except MatrixRequestError: except MatrixRequestError:
return [] return []
authenticated: List[u.User] = [] authenticated: List[UserID] = []
has_bot = self.has_bot has_bot = self.has_bot
for member_str in members: for member in members:
member = UserID(member_str) if p.Puppet.get_id_from_mxid(member) or member == self.az.bot_mxid:
if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid:
continue continue
user = await u.User.get_by_mxid(member).ensure_started() user = await u.User.get_by_mxid(member).ensure_started()
authenticated_through_bot = has_bot and user.relaybot_whitelisted authenticated_through_bot = has_bot and user.relaybot_whitelisted
if authenticated_through_bot or await user.has_full_access(allow_bot=True): if authenticated_through_bot or await user.has_full_access(allow_bot=True):
authenticated.append(user) authenticated.append(user.mxid)
return authenticated return authenticated
@classmethod async def cleanup_portal(self, message: str, puppets_only: bool = False, delete: bool = True
async def cleanup_room(cls, intent: IntentAPI, room_id: RoomID, message: str, ) -> None:
puppets_only: bool = False) -> None:
# TODO use the cleanup_room from BasePortal instead of this
try:
members = await intent.get_room_members(room_id)
except MatrixRequestError:
members = []
for user in members:
puppet = await p.Puppet.get_by_mxid(UserID(user), create=False)
if user != intent.mxid and (not puppets_only or puppet):
try:
if puppet:
await puppet.default_mxid_intent.leave_room(room_id)
else:
await intent.kick_user(room_id, user, message)
except (MatrixRequestError, IntentError):
pass
try:
await intent.leave_room(room_id)
except (MatrixRequestError, IntentError):
cls.log.warning(f"Failed to leave room {room_id} when cleaning up room", exc_info=True)
async def cleanup_portal(self, message: str, puppets_only: bool = False) -> None:
if self.username: if self.username:
try: try:
await self.main_intent.remove_room_alias(self.alias_localpart) await self.main_intent.remove_room_alias(self.alias_localpart)
except (MatrixRequestError, IntentError): except (MatrixRequestError, IntentError):
self.log.warning("Failed to remove alias when cleaning up room", exc_info=True) self.log.warning("Failed to remove alias when cleaning up room", exc_info=True)
await self.cleanup_room(self.main_intent, self.mxid, message, puppets_only) await self.cleanup_room(self.main_intent, self.mxid, message, puppets_only)
if delete:
async def unbridge(self) -> None: await self.delete()
await self.cleanup_portal("Room unbridged", puppets_only=True)
self.delete()
async def cleanup_and_delete(self) -> None:
await self.cleanup_portal("Portal deleted")
self.delete()
# endregion # endregion
# region Database conversion # region Database conversion
@@ -350,7 +326,10 @@ class BasePortal(MautrixBasePortal, ABC):
config=json.dumps(self.local_config), avatar_url=self.avatar_url, config=json.dumps(self.local_config), avatar_url=self.avatar_url,
encrypted=self.encrypted) encrypted=self.encrypted)
def delete(self) -> None: async def delete(self) -> None:
self.delete_sync()
def delete_sync(self) -> None:
try: try:
del self.by_tgid[self.tgid_full] del self.by_tgid[self.tgid_full]
except KeyError: except KeyError:
@@ -544,6 +523,7 @@ def init(context: Context) -> None:
global config global config
BasePortal.az, config, BasePortal.loop, BasePortal.bot = context.core BasePortal.az, config, BasePortal.loop, BasePortal.bot = context.core
BasePortal.matrix = context.mx BasePortal.matrix = context.mx
MautrixBasePortal.bridge = context.bridge
BasePortal.max_initial_member_sync = config["bridge.max_initial_member_sync"] BasePortal.max_initial_member_sync = config["bridge.max_initial_member_sync"]
BasePortal.sync_channel_members = config["bridge.sync_channel_members"] BasePortal.sync_channel_members = config["bridge.sync_channel_members"]
BasePortal.sync_matrix_state = config["bridge.sync_matrix_state"] BasePortal.sync_matrix_state = config["bridge.sync_matrix_state"]
+18 -38
View File
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, Dict, List, Optional, Tuple, Union, Any, TYPE_CHECKING from typing import Awaitable, Dict, Optional, Union, Any, TYPE_CHECKING
from html import escape as escape_html from html import escape as escape_html
from string import Template from string import Template
from abc import ABC from abc import ABC
@@ -28,11 +28,11 @@ from telethon.errors import (ChatNotModifiedError, PhotoExtInvalidError,
PhotoInvalidDimensionsError, PhotoSaveFileInvalidError, PhotoInvalidDimensionsError, PhotoSaveFileInvalidError,
RPCError) RPCError)
from telethon.tl.patched import Message, MessageService from telethon.tl.patched import Message, MessageService
from telethon.tl.types import ( from telethon.tl.types import (DocumentAttributeFilename, DocumentAttributeImageSize, GeoPoint,
DocumentAttributeFilename, DocumentAttributeImageSize, GeoPoint, InputChatUploadedPhoto, MessageActionChatEditPhoto, MessageMediaGeo,
InputChatUploadedPhoto, MessageActionChatEditPhoto, MessageMediaGeo, SendMessageCancelAction, SendMessageTypingAction, TypeInputPeer,
SendMessageCancelAction, SendMessageTypingAction, TypeInputPeer, TypeMessageEntity, UpdateNewMessage, InputMediaUploadedDocument,
UpdateNewMessage, InputMediaUploadedDocument, InputMediaUploadedPhoto) InputMediaUploadedPhoto)
from mautrix.types import (EventID, RoomID, UserID, ContentURI, MessageType, MessageEventContent, from mautrix.types import (EventID, RoomID, UserID, ContentURI, MessageType, MessageEventContent,
TextMessageEventContent, MediaMessageEventContent, Format, TextMessageEventContent, MediaMessageEventContent, Format,
@@ -87,7 +87,7 @@ class PortalMatrix(BasePortal, ABC):
message = await self._get_state_change_message(event, user, **kwargs) message = await self._get_state_change_message(event, user, **kwargs)
if not message: if not message:
return return
message, entities = formatter.matrix_to_telegram(message) message, entities = await formatter.matrix_to_telegram(self.bot.client, html=message)
response = await self.bot.client.send_message(self.peer, message, response = await self.bot.client.send_message(self.peer, message,
formatting_entities=entities) formatting_entities=entities)
space = self.tgid if self.peer_type == "channel" else self.bot.tgid space = self.tgid if self.peer_type == "channel" else self.bot.tgid
@@ -122,7 +122,7 @@ class PortalMatrix(BasePortal, ABC):
if user.tgid == source.tgid: if user.tgid == source.tgid:
return None return None
if self.peer_type == "user" and user.tgid == self.tgid: if self.peer_type == "user" and user.tgid == self.tgid:
self.delete() await self.delete()
return None return None
if isinstance(user, u.User) and await user.needs_relaybot(self): if isinstance(user, u.User) and await user.needs_relaybot(self):
if not self.bot: if not self.bot:
@@ -152,7 +152,7 @@ class PortalMatrix(BasePortal, ABC):
if self.peer_type == "user": if self.peer_type == "user":
await self.main_intent.leave_room(self.mxid) await self.main_intent.leave_room(self.mxid)
self.delete() await self.delete()
try: try:
del self.by_tgid[self.tgid_full] del self.by_tgid[self.tgid_full]
del self.by_mxid[self.mxid] del self.by_mxid[self.mxid]
@@ -214,27 +214,11 @@ class PortalMatrix(BasePortal, ABC):
elif content.msgtype == MessageType.EMOTE: elif content.msgtype == MessageType.EMOTE:
await self._apply_emote_format(sender, content) await self._apply_emote_format(sender, content)
@staticmethod
def _matrix_event_to_entities(event: Union[str, MessageEventContent]
) -> Tuple[str, Optional[List[TypeMessageEntity]]]:
try:
if isinstance(event, str):
message, entities = formatter.matrix_to_telegram(event)
elif isinstance(event, TextMessageEventContent) and event.format == Format.HTML:
message, entities = formatter.matrix_to_telegram(event.formatted_body)
else:
message, entities = formatter.matrix_text_to_telegram(event.body)
except KeyError:
message, entities = None, None
return message, entities
async def _handle_matrix_text(self, sender_id: TelegramID, event_id: EventID, async def _handle_matrix_text(self, sender_id: TelegramID, event_id: EventID,
space: TelegramID, client: 'MautrixTelegramClient', space: TelegramID, client: 'MautrixTelegramClient',
content: TextMessageEventContent, reply_to: TelegramID) -> None: content: TextMessageEventContent, reply_to: TelegramID) -> None:
if content.formatted_body and content.format == Format.HTML: message, entities = await formatter.matrix_to_telegram(client, text=content.body,
message, entities = formatter.matrix_to_telegram(content.formatted_body) html=content.formatted(Format.HTML))
else:
message, entities = formatter.matrix_text_to_telegram(content.body)
async with self.send_lock(sender_id): async with self.send_lock(sender_id):
lp = self.get_config("telegram_link_preview") lp = self.get_config("telegram_link_preview")
if content.get_edit(): if content.get_edit():
@@ -301,25 +285,21 @@ class PortalMatrix(BasePortal, ABC):
media = InputMediaUploadedDocument(file=file_handle, attributes=attributes, media = InputMediaUploadedDocument(file=file_handle, attributes=attributes,
mime_type=mime or "application/octet-stream") mime_type=mime or "application/octet-stream")
if caption: capt, entities = (await formatter.matrix_to_telegram(client, text=caption.body,
if caption.formatted_body and caption.format == Format.HTML: html=caption.formatted(Format.HTML))
caption, entities = formatter.matrix_to_telegram(caption.formatted_body) if caption else (None, None))
else:
caption, entities = formatter.matrix_text_to_telegram(content.body)
else:
caption, entities = None, None
async with self.send_lock(sender_id): async with self.send_lock(sender_id):
if await self._matrix_document_edit(client, content, space, caption, media, event_id): if await self._matrix_document_edit(client, content, space, capt, media, event_id):
return return
try: try:
response = await client.send_media(self.peer, media, reply_to=reply_to, response = await client.send_media(self.peer, media, reply_to=reply_to,
caption=caption, entities=entities) caption=capt, entities=entities)
except (PhotoInvalidDimensionsError, PhotoSaveFileInvalidError, PhotoExtInvalidError): except (PhotoInvalidDimensionsError, PhotoSaveFileInvalidError, PhotoExtInvalidError):
media = InputMediaUploadedDocument(file=media.file, mime_type=mime, media = InputMediaUploadedDocument(file=media.file, mime_type=mime,
attributes=attributes) attributes=attributes)
response = await client.send_media(self.peer, media, reply_to=reply_to, response = await client.send_media(self.peer, media, reply_to=reply_to,
caption=caption, entities=entities) caption=capt, entities=entities)
self._add_telegram_message_to_db(event_id, space, 0, response) self._add_telegram_message_to_db(event_id, space, 0, response)
await self._send_delivery_receipt(event_id) await self._send_delivery_receipt(event_id)
@@ -346,7 +326,7 @@ class PortalMatrix(BasePortal, ABC):
except (KeyError, ValueError): except (KeyError, ValueError):
self.log.exception("Failed to parse location") self.log.exception("Failed to parse location")
return None return None
caption, entities = formatter.matrix_text_to_telegram(content.body) caption, entities = await formatter.matrix_to_telegram(client, text=content.body)
media = MessageMediaGeo(geo=GeoPoint(lat, long, access_hash=0)) media = MessageMediaGeo(geo=GeoPoint(lat, long, access_hash=0))
async with self.send_lock(sender_id): async with self.send_lock(sender_id):
+31 -28
View File
@@ -97,7 +97,7 @@ class PortalMetadata(BasePortal, ABC):
pass pass
try: try:
existing = self.by_tgid[(new_id, new_id)] existing = self.by_tgid[(new_id, new_id)]
existing.delete() existing.delete_sync()
except KeyError: except KeyError:
pass pass
self.db_instance.edit(tgid=new_id, tg_receiver=new_id, peer_type=self.peer_type) self.db_instance.edit(tgid=new_id, tg_receiver=new_id, peer_type=self.peer_type)
@@ -295,17 +295,14 @@ class PortalMetadata(BasePortal, ABC):
async def _create_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User], async def _create_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
invites: InviteList) -> Optional[RoomID]: invites: InviteList) -> Optional[RoomID]:
direct = self.peer_type == "user"
if invites is None:
invites = []
if self.mxid: if self.mxid:
return self.mxid return self.mxid
elif not self.allow_bridging:
if not self.allow_bridging:
return None return None
direct = self.peer_type == "user"
invites = invites or []
if not entity: if not entity:
entity = await self.get_entity(user) entity = await self.get_entity(user)
self.log.trace("Fetched data: %s", entity) self.log.trace("Fetched data: %s", entity)
@@ -576,32 +573,38 @@ class PortalMetadata(BasePortal, ABC):
if self.max_initial_member_sync < 0 if self.max_initial_member_sync < 0
else len(allowed_tgids) < self.max_initial_member_sync - 10) else len(allowed_tgids) < self.max_initial_member_sync - 10)
and (self.megagroup or self.peer_type != "channel")) and (self.megagroup or self.peer_type != "channel"))
if trust_member_list: if not trust_member_list:
joined_mxids = await self.main_intent.get_room_members(self.mxid) return
for user_mxid in joined_mxids:
if user_mxid == self.az.bot_mxid:
continue
puppet_id = p.Puppet.get_id_from_mxid(user_mxid)
if puppet_id and puppet_id not in allowed_tgids:
if self.bot and puppet_id == self.bot.tgid:
self.bot.remove_chat(self.tgid)
try:
await self.main_intent.kick_user(self.mxid, user_mxid,
"User had left this Telegram chat.")
except MForbidden:
pass
continue
mx_user = u.User.get_by_mxid(user_mxid, create=False)
if mx_user and mx_user.is_bot and mx_user.tgid not in allowed_tgids:
await mx_user.unregister_portal(*self.tgid_full)
if mx_user and not self.has_bot and mx_user.tgid not in allowed_tgids: for user_mxid in await self.main_intent.get_room_members(self.mxid):
if user_mxid == self.az.bot_mxid:
continue
puppet_id = p.Puppet.get_id_from_mxid(user_mxid)
if puppet_id:
if puppet_id in allowed_tgids:
continue
if self.bot and puppet_id == self.bot.tgid:
self.bot.remove_chat(self.tgid)
try:
await self.main_intent.kick_user(self.mxid, user_mxid,
"User had left this Telegram chat.")
except MForbidden:
pass
continue
mx_user = u.User.get_by_mxid(user_mxid, create=False)
if mx_user:
if mx_user.tgid in allowed_tgids:
continue
if mx_user.is_bot:
await mx_user.unregister_portal(*self.tgid_full)
if not self.has_bot:
try: try:
await self.main_intent.kick_user(self.mxid, mx_user.mxid, await self.main_intent.kick_user(self.mxid, mx_user.mxid,
"You had left this Telegram chat.") "You had left this Telegram chat.")
except MForbidden: except MForbidden:
pass pass
continue
async def _add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None async def _add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None
) -> None: ) -> None:
+20 -15
View File
@@ -48,7 +48,7 @@ config: Optional['Config'] = None
SearchResult = NamedTuple('SearchResult', puppet='pu.Puppet', similarity=int) SearchResult = NamedTuple('SearchResult', puppet='pu.Puppet', similarity=int)
METRIC_LOGGED_IN = Gauge('bridge_logged_in', 'Users logged into bridge') METRIC_LOGGED_IN = Gauge('bridge_logged_in', 'Users logged into bridge')
METRIC_CONNECTED = Gauge('bridge_connected', 'Users connected') METRIC_CONNECTED = Gauge('bridge_connected', 'Users connected to Telegram')
class User(AbstractUser, BaseUser): class User(AbstractUser, BaseUser):
@@ -201,16 +201,12 @@ class User(AbstractUser, BaseUser):
async def start(self, delete_unless_authenticated: bool = False) -> 'User': async def start(self, delete_unless_authenticated: bool = False) -> 'User':
await super().start() await super().start()
self._track_metric(METRIC_CONNECTED, True)
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())
if config["metrics.enabled"]:
self._track_connection_task = self.loop.create_task(self._track_connection())
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()
self._track_metric(METRIC_CONNECTED, False)
self.client.session.delete() self.client.session.delete()
return self return self
@@ -230,6 +226,9 @@ class User(AbstractUser, BaseUser):
self._track_metric(METRIC_CONNECTED, False) self._track_metric(METRIC_CONNECTED, False)
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:
self._track_connection_task = self.loop.create_task(self._track_connection())
try: try:
await self.update_info(info) await self.update_info(info)
except Exception: except Exception:
@@ -305,11 +304,14 @@ class User(AbstractUser, BaseUser):
for _, portal in self.portals.items(): for _, portal in self.portals.items():
if not portal or portal.deleted or not portal.mxid or portal.has_bot: if not portal or portal.deleted or not portal.mxid or portal.has_bot:
continue continue
try: if portal.peer_type == "user":
await portal.main_intent.kick_user(portal.mxid, self.mxid, await portal.cleanup_portal("Logged out of Telegram")
"Logged out of Telegram.") else:
except MatrixRequestError: try:
pass await portal.main_intent.kick_user(portal.mxid, self.mxid,
"Logged out of Telegram.")
except MatrixRequestError:
pass
self.portals = {} self.portals = {}
self.contacts = [] self.contacts = []
await self.save(portals=True, contacts=True) await self.save(portals=True, contacts=True)
@@ -324,6 +326,7 @@ class User(AbstractUser, BaseUser):
if not ok: if not ok:
return False return False
self.delete() self.delete()
await self.stop()
self._track_metric(METRIC_LOGGED_IN, False) self._track_metric(METRIC_LOGGED_IN, False)
return True return True
@@ -459,7 +462,8 @@ class User(AbstractUser, BaseUser):
# region Class instance lookup # region Class instance lookup
@classmethod @classmethod
def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['User']: def get_by_mxid(cls, mxid: UserID, create: bool = True, check_db: bool = True
) -> Optional['User']:
if not mxid: if not mxid:
raise ValueError("Matrix ID can't be empty") raise ValueError("Matrix ID can't be empty")
@@ -468,10 +472,11 @@ class User(AbstractUser, BaseUser):
except KeyError: except KeyError:
pass pass
user = DBUser.get_by_mxid(mxid) if check_db:
if user: user = DBUser.get_by_mxid(mxid)
user = cls.from_db(user) if user:
return user user = cls.from_db(user)
return user
if create: if create:
user = cls(mxid) user = cls(mxid)
+5 -3
View File
@@ -30,7 +30,7 @@ from telethon.errors import (AuthBytesInvalidError, AuthKeyInvalidError, Locatio
SecurityError, FileIdInvalidError) SecurityError, FileIdInvalidError)
from mautrix.appservice import IntentAPI from mautrix.appservice import IntentAPI
from mautrix.types import EncryptedFile from mautrix.util.network_retry import call_with_net_retry
from ..tgclient import MautrixTelegramClient from ..tgclient import MautrixTelegramClient
from ..db import TelegramFile as DBTelegramFile from ..db import TelegramFile as DBTelegramFile
@@ -145,7 +145,8 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
if encrypt: if encrypt:
file, decryption_info = encrypt_attachment(file) file, decryption_info = encrypt_attachment(file)
upload_mime_type = "application/octet-stream" upload_mime_type = "application/octet-stream"
content_uri = await intent.upload_media(file, upload_mime_type) content_uri = await call_with_net_retry(intent.upload_media, file, upload_mime_type,
_action="upload media")
if decryption_info: if decryption_info:
decryption_info.url = content_uri decryption_info.url = content_uri
@@ -246,7 +247,8 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten
if encrypt and encrypt_attachment: if encrypt and encrypt_attachment:
file, decryption_info = encrypt_attachment(file) file, decryption_info = encrypt_attachment(file)
upload_mime_type = "application/octet-stream" upload_mime_type = "application/octet-stream"
content_uri = await intent.upload_media(file, upload_mime_type) content_uri = await call_with_net_retry(intent.upload_media, file, upload_mime_type,
_action="upload media")
if decryption_info: if decryption_info:
decryption_info.url = content_uri decryption_info.url = content_uri
+30 -28
View File
@@ -141,6 +141,12 @@ class ProvisioningAPI(AuthAPI):
return self.get_error_response(403, "not_enough_permissions", return self.get_error_response(403, "not_enough_permissions",
"You do not have the permissions to bridge that room.") "You do not have the permissions to bridge that room.")
is_logged_in = user is not None and await user.is_logged_in()
acting_user = user if is_logged_in else self.context.bot
if not acting_user:
return self.get_login_response(status=403, errcode="not_logged_in",
error="You are not logged in and there is no relay bot.")
portal = Portal.get_by_tgid(tgid, peer_type=peer_type) portal = Portal.get_by_tgid(tgid, peer_type=peer_type)
if portal.mxid == room_id: if portal.mxid == room_id:
return self.get_error_response(200, "bridge_exists", return self.get_error_response(200, "bridge_exists",
@@ -157,35 +163,30 @@ class ProvisioningAPI(AuthAPI):
"Telegram chat is already bridged to another " "Telegram chat is already bridged to another "
"Matrix room.") "Matrix room.")
is_logged_in = user is not None and await user.is_logged_in() async with portal._room_create_lock:
acting_user = user if is_logged_in else self.context.bot entity: Optional[TypeChat] = None
if not acting_user: try:
return self.get_login_response(status=403, errcode="not_logged_in", entity = await acting_user.client.get_entity(portal.peer)
error="You are not logged in and there is no relay bot.") except Exception:
self.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer)
entity: Optional[TypeChat] = None if not entity or isinstance(entity, (ChatForbidden, ChannelForbidden)):
try: if is_logged_in:
entity = await acting_user.client.get_entity(portal.peer) return self.get_error_response(403, "user_not_in_chat",
except Exception: "Failed to get info of Telegram chat. "
self.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer) "Are you in the chat?")
return self.get_error_response(403, "bot_not_in_chat",
if not entity or isinstance(entity, (ChatForbidden, ChannelForbidden)):
if is_logged_in:
return self.get_error_response(403, "user_not_in_chat",
"Failed to get info of Telegram chat. " "Failed to get info of Telegram chat. "
"Are you in the chat?") "Is the relay bot in the chat?")
return self.get_error_response(403, "bot_not_in_chat",
"Failed to get info of Telegram chat. "
"Is the relay bot in the chat?")
direct = False portal.mxid = room_id
portal.by_mxid[portal.mxid] = portal
(portal.title, portal.about, levels,
portal.encrypted) = await get_initial_state(self.az.intent, room_id)
portal.photo_id = ""
await portal.save()
portal.mxid = room_id asyncio.ensure_future(portal.update_matrix_room(user, entity, direct=False, levels=levels),
portal.title, portal.about, levels = await get_initial_state(self.az.intent, room_id)
portal.photo_id = ""
await portal.save()
asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
loop=self.loop) loop=self.loop)
return web.Response(status=202, body="{}") return web.Response(status=202, body="{}")
@@ -216,7 +217,7 @@ class ProvisioningAPI(AuthAPI):
"You do not have the permissions to bridge that room.") "You do not have the permissions to bridge that room.")
try: try:
title, about, _ = await get_initial_state(self.az.intent, room_id) title, about, _, encrypted = await get_initial_state(self.az.intent, room_id)
except (MatrixRequestError, IntentError): except (MatrixRequestError, IntentError):
return self.get_error_response(403, "bot_not_in_room", return self.get_error_response(403, "bot_not_in_room",
"The bridge bot is not in the given room.") "The bridge bot is not in the given room.")
@@ -240,11 +241,12 @@ class ProvisioningAPI(AuthAPI):
"group": "chat", "group": "chat",
}[type] }[type]
portal = Portal(tgid=TelegramID(0), mxid=room_id, title=title, about=about, peer_type=type) portal = Portal(tgid=TelegramID(0), mxid=room_id, title=title, about=about, peer_type=type,
encrypted=encrypted)
try: try:
await portal.create_telegram_chat(user, supergroup=supergroup) await portal.create_telegram_chat(user, supergroup=supergroup)
except ValueError as e: except ValueError as e:
portal.delete() await portal.delete()
return self.get_error_response(500, "unknown_error", e.args[0]) return self.get_error_response(500, "unknown_error", e.args[0])
return web.json_response({ return web.json_response({
+3 -3
View File
@@ -3,8 +3,8 @@ alembic>=1,<2
ruamel.yaml>=0.15.35,<0.17 ruamel.yaml>=0.15.35,<0.17
python-magic>=0.4,<0.5 python-magic>=0.4,<0.5
commonmark>=0.8,<0.10 commonmark>=0.8,<0.10
aiohttp>=3,<3.7 aiohttp>=3,<4
yarl<1.6 yarl>=1,<2
mautrix==0.8.0rc1 mautrix>=0.8.3,<0.9
telethon>=1.17,<1.18 telethon>=1.17,<1.18
telethon-session-sqlalchemy>=0.2.14,<0.3 telethon-session-sqlalchemy>=0.2.14,<0.3