Merge branch 'master' into python3.5

This commit is contained in:
Tulir Asokan
2018-02-13 13:55:10 +02:00
14 changed files with 912 additions and 555 deletions
+5 -3
View File
@@ -77,17 +77,19 @@
* [ ] Option to use bot to relay messages for unauthenticated Matrix users
* [ ] Option to use own Matrix account for messages sent from other Telegram clients
* [Commands](https://github.com/tulir/mautrix-telegram/wiki/Management-commands)
* [x] Logging in and out (`login` + code entering, `logout`)
* [x] Logging in and out (`login` + code entering)
* [x] Logging out
* [ ] Registering (`register`)
* [x] Searching for users (`search`)
* [ ] Searching contacts locally
* [x] Starting private chats (`pm`)
* [x] Joining chats with invite links (`join`)
* [x] Creating a Telegram chat for an existing Matrix room (`create`)
* [x] Upgrading the chat of a portal room into a supergroup (`upgrade`)
* [x] Change username of supergroup/channel (`groupname`)
* [x] Getting the Telegram invite link to a Matrix room (`invitelink`)
* [x] Clean up and forget a portal room (`deleteportal`)
* Bridge administration
* [x] Clean up and forget a portal room (`deleteportal`)
* [ ] Setting Matrix-only power levels (`powerlevel`)
† 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
+5 -2
View File
@@ -67,6 +67,9 @@ bridge:
# Warning: Using this on a client with native replies will not look good: the message will have
# a native quote AND a non-native quote.
link_in_reply: False
# Show message editing as a reply to the original message.
# If this is false, message edits are not shown at all, as Matrix does not support editing yet.
edits_as_replies: False
# The prefix for commands. Only required in non-management rooms.
command_prefix: "!tg"
@@ -74,13 +77,13 @@ bridge:
# Whitelist of user IDs that are allowed to use this bridge. Leave empty to disable.
# You can enter a domain without the localpart to allow all users from that homeserver to use the bridge.
whitelist:
- "internal-hs.example.com"
- "internal.example.com"
- "@user:public.example.com"
# Admins can do things like delete portal rooms. Here you must specify the exact MXID, domains
# are not accepted.
admins:
- "@admin:internal-hs.example.com"
- "@admin:internal.example.com"
# Telegram config
telegram:
-524
View File
@@ -1,524 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import markdown
import logging
import asyncio
from mautrix_appservice import MatrixRequestError
from telethon.errors import *
from telethon.tl.types import *
from telethon.tl.functions.messages import ImportChatInviteRequest, CheckChatInviteRequest
from telethon.tl.functions.channels import JoinChannelRequest
from . import puppet as pu, portal as po
command_handlers = {}
def command_handler(func):
command_handlers[func.__name__] = func
return func
def format_duration(seconds):
def pluralize(count, singular): return singular if count == 1 else singular + "s"
def include(count, word): return f"{count} {pluralize(count, word)}" if count > 0 else ""
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
days, hours = divmod(hours, 24)
parts = [a for a in [
include(days, "day"),
include(hours, "hour"),
include(minutes, "minute"),
include(seconds, "second")] if a]
if len(parts) > 2:
return "{} and {}".format(", ".join(parts[:-1]), parts[-1])
return " and ".join(parts)
class CommandEvent:
def __init__(self, az, command_prefix, room, sender, args, is_management, is_portal):
self.az = az
self.command_prefix = command_prefix
self.room_id = room
self.sender = sender
self.args = args
self.is_management = is_management
self.is_portal = is_portal
def reply(self, message, allow_html=False, render_markdown=True):
if not self.room_id:
raise AttributeError("the reply function can only be used from within"
"the `CommandHandler.run` context manager")
message = message.replace("$cmdprefix+sp ",
"" if self.is_management else f"{self.command_prefix} ")
message = message.replace("$cmdprefix", self.command_prefix)
html = None
if render_markdown:
html = markdown.markdown(message, safe_mode="escape" if allow_html else False)
elif allow_html:
html = message
return self.az.intent.send_notice(self.room_id, message, html=html)
class CommandHandler:
log = logging.getLogger("mau.commands")
def __init__(self, context):
self.az, self.db, self.config, self.loop = context
self.command_prefix = self.config["bridge.command_prefix"]
# region Utility functions for handling commands
async def handle(self, room, sender, command, args, is_management, is_portal):
evt = CommandEvent(self.az, self.command_prefix, room, sender, args, is_management,
is_portal)
command = command.lower()
try:
command = command_handlers[command]
except KeyError:
if sender.command_status and "next" in sender.command_status:
args.insert(0, command)
command = sender.command_status["next"]
else:
command = command_handlers["unknown_command"]
try:
await command(self, evt)
except FloodWaitError as e:
return evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
except Exception:
self.log.exception(f"Fatal error handling command "
+ f"'$cmdprefix {command} {''.join(args)}' from {sender.mxid}")
return evt.reply("Fatal error while handling command. Check logs for more details.")
# endregion
# region Command handlers
@command_handler
async def ping(self, evt):
if not evt.sender.logged_in:
return await evt.reply("You're not logged in.")
me = await evt.sender.client.get_me()
if me:
return await evt.reply(f"You're logged in as @{me.username}")
else:
return await evt.reply("You're not logged in.")
# region Authentication commands
@command_handler
def register(self, evt):
return evt.reply("Not yet implemented.")
@command_handler
async def login(self, evt):
if not evt.is_management:
return await evt.reply(
"`login` is a restricted command: you may only run it in management rooms.")
elif evt.sender.logged_in:
return await evt.reply("You are already logged in.")
elif len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp login <phone number>`")
phone_number = evt.args[0]
await evt.sender.client.sign_in(phone_number)
evt.sender.command_status = {
"next": command_handlers["enter_code"],
"action": "Login",
}
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
@command_handler
async def enter_code(self, evt):
if not evt.sender.command_status:
return await evt.reply(
"Request a login code first with `$cmdprefix+sp login <phone>`")
elif len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter_code <code>`")
try:
user = await evt.sender.client.sign_in(code=evt.args[0])
asyncio.ensure_future(evt.sender.post_login(user), loop=self.loop)
evt.sender.command_status = None
return await evt.reply(f"Successfully logged in as @{user.username}")
except PhoneNumberUnoccupiedError:
return await evt.reply("That phone number has not been registered."
"Please register with `$cmdprefix+sp register <phone>`.")
except PhoneCodeExpiredError:
return await evt.reply(
"Phone code expired. Try again with `$cmdprefix+sp login <phone>`.")
except PhoneCodeInvalidError:
return await evt.reply("Invalid phone code.")
except PhoneNumberAppSignupForbiddenError:
return await evt.reply(
"Your phone number does not allow 3rd party apps to sign in.")
except PhoneNumberFloodError:
return await evt.reply(
"Your phone number has been temporarily blocked for flooding. "
"The block is usually applied for around a day.")
except PhoneNumberBannedError:
return await evt.reply("Your phone number has been banned from Telegram.")
except SessionPasswordNeededError:
evt.sender.command_status = {
"next": command_handlers["enter_password"],
"action": "Login (password entry)",
}
return await evt.reply("Your account has two-factor authentication."
"Please send your password here.")
except Exception:
self.log.exception("Error sending phone code")
return await evt.reply("Unhandled exception while sending code."
"Check console for more details.")
@command_handler
async def enter_password(self, evt):
if not evt.sender.command_status:
return await evt.reply(
"Request a login code first with `$cmdprefix+sp login <phone>`")
elif len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter_password <password>`")
try:
user = await evt.sender.client.sign_in(password=evt.args[0])
asyncio.ensure_future(evt.sender.post_login(user), loop=self.loop)
evt.sender.command_status = None
return await evt.reply(f"Successfully logged in as @{user.username}")
except PasswordHashInvalidError:
return await evt.reply("Incorrect password.")
except Exception:
self.log.exception("Error sending password")
return await evt.reply("Unhandled exception while sending password. "
"Check console for more details.")
@command_handler
async def logout(self, evt):
if not evt.sender.logged_in:
return await evt.reply("You're not logged in.")
if await evt.sender.log_out():
return await evt.reply("Logged out successfully.")
return await evt.reply("Failed to log out.")
# endregion
# region Telegram interaction commands
@command_handler
async def search(self, evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
elif not evt.sender.logged_in:
return await evt.reply("This command requires you to be logged in.")
force_remote = False
if evt.args[0] in {"-r", "--remote"}:
force_remote = True
evt.args.pop(0)
query = " ".join(evt.args)
if force_remote and len(query) < 5:
return await evt.reply("Minimum length of query for remote search is 5 characters.")
results, remote = await evt.sender.search(query, force_remote)
if not results:
if len(query) < 5 and remote:
return await evt.reply("No local results. "
"Minimum length of remote query is 5 characters.")
return await evt.reply("No results 3:")
reply = []
if remote:
reply += ["**Results from Telegram server:**", ""]
else:
reply += ["**Results in contacts:**", ""]
reply += [(f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
+ f"{puppet.id} ({similarity}% match)")
for puppet, similarity in results]
# TODO somehow show remote channel results when joining by alias is possible?
return await evt.reply("\n".join(reply))
@command_handler
async def pm(self, evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
elif not evt.sender.logged_in:
return await evt.reply("This command requires you to be logged in.")
user = await evt.sender.client.get_entity(evt.args[0])
if not user:
return await evt.reply("User not found.")
elif not isinstance(user, User):
return await evt.reply("That doesn't seem to be a user.")
portal = po.Portal.get_by_entity(user, evt.sender.tgid)
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
return await evt.reply(
f"Created private chat room with {pu.Puppet.get_displayname(user, False)}")
@command_handler
async def invitelink(self, evt):
if not evt.sender.logged_in:
return await evt.reply("This command requires you to be logged in.")
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
if portal.peer_type == "user":
return await evt.reply("You can't invite users to private chats.")
try:
link = await portal.get_invite_link(evt.sender)
return await evt.reply(f"Invite link to {portal.title}: {link}")
except ValueError as e:
return await evt.reply(e.args[0])
except ChatAdminRequiredError:
return await evt.reply("You don't have the permission to create an invite link.")
@command_handler
async def deleteportal(self, evt):
if not evt.sender.logged_in:
return await evt.reply("This command requires you to be logged in.")
elif not evt.sender.is_admin:
return await evt.reply("This is command requires administrator privileges.")
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
for user in await portal.main_intent.get_room_members(portal.mxid):
if user != portal.main_intent.mxid:
try:
await portal.main_intent.kick(portal.mxid, user, "Portal deleted.")
except MatrixRequestError:
pass
await portal.main_intent.leave_room(portal.mxid)
portal.delete()
@staticmethod
def _strip_prefix(value, prefixes):
for prefix in prefixes:
if value.startswith(prefix):
return value[len(prefix):]
return value
@command_handler
async def join(self, evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
elif not evt.sender.logged_in:
return await evt.reply("This command requires you to be logged in.")
regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)")
arg = regex.match(evt.args[0])
if not arg:
return await evt.reply("That doesn't look like a Telegram invite link.")
arg = arg.group(1)
if arg.startswith("joinchat/"):
invite_hash = arg[len("joinchat/"):]
try:
await evt.sender.client(CheckChatInviteRequest(invite_hash))
except InviteHashInvalidError:
return await evt.reply("Invalid invite link.")
except InviteHashExpiredError:
return await evt.reply("Invite link expired.")
try:
updates = evt.sender.client(ImportChatInviteRequest(invite_hash))
except UserAlreadyParticipantError:
return await evt.reply("You are already in that chat.")
else:
channel = await evt.sender.client.get_entity(arg)
if not channel:
return await evt.reply("Channel/supergroup not found.")
updates = await evt.sender.client(JoinChannelRequest(channel))
for chat in updates.chats:
portal = po.Portal.get_by_entity(chat)
if portal.mxid:
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
return await evt.reply(f"Created room for {portal.title}")
else:
await portal.invite_matrix([evt.sender.mxid])
return await evt.reply(f"Invited you to portal of {portal.title}")
@command_handler
async def create(self, evt):
type = evt.args[0] if len(evt.args) > 0 else "group"
if type not in {"chat", "group", "supergroup", "channel"}:
return await evt.reply(
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`")
elif not evt.sender.logged_in:
return await evt.reply("This command requires you to be logged in.")
if po.Portal.get_by_mxid(evt.room_id):
return await evt.reply("This is already a portal room.")
state = await self.az.intent.get_room_state(evt.room_id)
title = None
about = None
levels = None
for event in state:
if event["type"] == "m.room.name":
title = event["content"]["name"]
elif event["type"] == "m.room.topic":
about = event["content"]["topic"]
elif event["type"] == "m.room.power_levels":
levels = event["content"]
if not title:
return await evt.reply("Please set a title before creating a Telegram chat.")
elif (not levels or not levels["users"] or self.az.intent.mxid not in levels["users"] or
levels["users"][self.az.intent.mxid] < 100):
return await evt.reply(f"Please give "
+ f"[the bridge bot](https://matrix.to/#/{self.az.intent.mxid})"
+ f" a power level of 100 before creating a Telegram chat.")
else:
for user, level in levels["users"].items():
if level >= 100 and user != self.az.intent.mxid:
return await evt.reply(
f"Please make sure only the bridge bot has power level above"
+ f"99 before creating a Telegram chat.\n\n"
+ f"Use power level 95 instead of 100 for admins.")
supergroup = type == "supergroup"
type = {
"supergroup": "channel",
"channel": "channel",
"chat": "chat",
"group": "chat",
}[type]
portal = po.Portal(tgid=None, mxid=evt.room_id, title=title, about=about, peer_type=type)
try:
await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
except ValueError as e:
return await evt.reply(e.args[0])
return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
@command_handler
async def upgrade(self, evt):
if not evt.sender.logged_in:
return await evt.reply("This command requires you to be logged in.")
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type == "channel":
return await evt.reply("This is already a supergroup or a channel.")
elif portal.peer_type == "user":
return await evt.reply("You can't upgrade private chats.")
try:
await portal.upgrade_telegram_chat(evt.sender)
return await evt.reply(f"Group upgraded to supergroup. New ID: {portal.tgid}")
except ChatAdminRequiredError:
return await evt.reply("You don't have the permission to upgrade this group.")
except ValueError as e:
return await evt.reply(e.args[0])
@command_handler
async def groupname(self, evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp groupname <name/->`")
if not evt.sender.logged_in:
return await evt.reply("This command requires you to be logged in.")
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type != "channel":
return await evt.reply("Only channels and supergroups have usernames.")
try:
await portal.set_telegram_username(evt.sender,
evt.args[0] if evt.args[0] != "-" else "")
if portal.username:
return await evt.reply(f"Username of channel changed to {portal.username}.")
else:
return await evt.reply(f"Channel is now private.")
except ChatAdminRequiredError:
return await evt.reply(
"You don't have the permission to set the username of this channel.")
except UsernameNotModifiedError:
if portal.username:
return await evt.reply("That is already the username of this channel.")
else:
return await evt.reply("This channel is already private")
except UsernameOccupiedError:
return await evt.reply("That username is already in use.")
except UsernameInvalidError:
return await evt.reply("Invalid username")
# endregion
# region Command-related commands
@command_handler
def cancel(self, evt):
if evt.sender.command_status:
action = evt.sender.command_status["action"]
evt.sender.command_status = None
return evt.reply(f"{action} cancelled.")
else:
return evt.reply("No ongoing command.")
@command_handler
def unknown_command(self, evt):
return evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
@command_handler
def help(self, evt):
if evt.is_management:
management_status = ("This is a management room: prefixing commands "
"with `$cmdprefix` is not required.\n")
elif evt.is_portal:
management_status = ("**This is a portal room**: you must always "
"prefix commands with `$cmdprefix`.\n"
"Management commands will not be sent to Telegram.")
else:
management_status = ("**This is not a management room**: you must "
"prefix commands with `$cmdprefix`.\n")
help = """\n
#### Generic bridge commands
**help** - Show this help message.
**cancel** - Cancel an ongoing action (such as login).
#### Authentication
**login** <_phone_> - Request an authentication code.
**logout** - Log out from Telegram.
**ping** - Check if you're logged into Telegram.
#### Initiating chats
**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users.
**pm** <_identifier_> - Open a private chat with the given Telegram user. The
identifier is either the internal user ID, the username or
the phone number.
**join** <_link_> - Join a chat with an invite link.
**create** [_type_] - Create a Telegram chat of the given type for the current
Matrix room. The type is either `group`, `supergroup` or
`channel` (defaults to `group`).
#### Portal management
**upgrade** - Upgrade a normal Telegram group to a supergroup.
**invitelink** - Get a Telegram invite link to the current chat.
**deleteportal** - Forget the current portal room. Only works for group chats; to delete
a private chat portal, simply leave the room.
**groupname** <_name_|`-`> - Change the username of a supergroup/channel. To disable, use a dash
(`-`) as the name.
"""
return evt.reply(management_status + help)
# endregion
# endregion
+2
View File
@@ -0,0 +1,2 @@
from .handler import command_handler, CommandHandler
from . import clean_rooms, auth, meta, telegram
+119
View File
@@ -0,0 +1,119 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
from telethon.errors import *
from . import command_handler
@command_handler(needs_auth=False)
async def ping(evt):
if not evt.sender.logged_in:
return await evt.reply("You're not logged in.")
me = await evt.sender.client.get_me()
if me:
return await evt.reply(f"You're logged in as @{me.username}")
else:
return await evt.reply("You're not logged in.")
@command_handler(needs_auth=False, management_only=True)
def register(evt):
return evt.reply("Not yet implemented.")
@command_handler(needs_auth=False, management_only=True)
async def login(evt):
if evt.sender.logged_in:
return await evt.reply("You are already logged in.")
elif len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp login <phone number>`")
phone_number = evt.args[0]
await evt.sender.client.sign_in(phone_number)
evt.sender.command_status = {
"next": enter_code,
"action": "Login",
}
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
@command_handler(needs_auth=False)
async def enter_code(evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
try:
user = await evt.sender.client.sign_in(code=evt.args[0])
asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
evt.sender.command_status = None
return await evt.reply(f"Successfully logged in as @{user.username}")
except PhoneNumberUnoccupiedError:
return await evt.reply("That phone number has not been registered."
"Please register with `$cmdprefix+sp register <phone>`.")
except PhoneCodeExpiredError:
return await evt.reply(
"Phone code expired. Try again with `$cmdprefix+sp login <phone>`.")
except PhoneCodeInvalidError:
return await evt.reply("Invalid phone code.")
except PhoneNumberAppSignupForbiddenError:
return await evt.reply(
"Your phone number does not allow 3rd party apps to sign in.")
except PhoneNumberFloodError:
return await evt.reply(
"Your phone number has been temporarily blocked for flooding. "
"The block is usually applied for around a day.")
except PhoneNumberBannedError:
return await evt.reply("Your phone number has been banned from Telegram.")
except SessionPasswordNeededError:
evt.sender.command_status = {
"next": enter_password,
"action": "Login (password entry)",
}
return await evt.reply("Your account has two-factor authentication."
"Please send your password here.")
except Exception:
evt.log.exception("Error sending phone code")
return await evt.reply("Unhandled exception while sending code."
"Check console for more details.")
@command_handler(needs_auth=False)
async def enter_password(evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
try:
user = await evt.sender.client.sign_in(password=evt.args[0])
asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
evt.sender.command_status = None
return await evt.reply(f"Successfully logged in as @{user.username}")
except PasswordHashInvalidError:
return await evt.reply("Incorrect password.")
except Exception:
evt.log.exception("Error sending password")
return await evt.reply("Unhandled exception while sending password. "
"Check console for more details.")
@command_handler(needs_auth=False)
async def logout(evt):
if not evt.sender.logged_in:
return await evt.reply("You're not logged in.")
if await evt.sender.log_out():
return await evt.reply("Logged out successfully.")
return await evt.reply("Failed to log out.")
+170
View File
@@ -0,0 +1,170 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from mautrix_appservice import MatrixRequestError
from . import command_handler
from .. import puppet as pu, portal as po
async def _find_rooms(intent):
management_rooms = []
unidentified_rooms = []
portals = []
empty_portals = []
rooms = await intent.get_joined_rooms()
for room in rooms:
portal = po.Portal.get_by_mxid(room)
if not portal:
try:
members = await intent.get_room_members(room)
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)
else:
management_rooms.append((room, other_member))
else:
unidentified_rooms.append(room)
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, portals, empty_portals
@command_handler(needs_admin=True, name="clean-rooms")
async def clean_rooms(evt):
if not evt.is_management:
return await evt.reply("`clean-rooms` is a particularly spammy command. Please don't "
"run it in non-management rooms.")
management_rooms, unidentified_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}. [P{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("#### Inactive portal rooms (I)")
reply += ([f"{n}. [E{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)"),
"",
("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."),
"",
("Please note that you will have to re-run `$cmdprefix+sp cleanrooms` "
"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, portals, empty_portals),
"action": "Room cleaning",
}
return await evt.reply("\n".join(reply))
async def set_rooms_to_clean(evt, management_rooms, unidentified_rooms, portals, empty_portals):
command = evt.args[0]
rooms_to_clean = []
if command == "clean-recommended":
rooms_to_clean = empty_portals + 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]
if "M" in groups_to_clean:
rooms_to_clean += 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
elif command == "clean-range":
try:
range = evt.args[1]
group, range = range[0], range[1:]
start, end = range.split("-")
start, end = int(start), int(end)
if group == "M":
group = management_rooms
elif group == "A":
group = portals
elif group == "U":
group = unidentified_rooms
elif group == "I":
group = empty_portals
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):
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
elif isinstance(room, str):
await po.Portal.cleanup_room(evt.az.intent, room, type="Room")
cleaned += 1
evt.sender.command_status = None
await evt.reply(f"{cleaned} rooms cleaned up successfully.")
else:
await evt.reply("Room cleaning cancelled.")
+115
View File
@@ -0,0 +1,115 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import markdown
import logging
from telethon.errors import FloodWaitError
command_handlers = {}
def command_handler(needs_auth=True, management_only=False, needs_admin=False, name=None):
def decorator(func):
def wrapper(evt):
if management_only and not evt.is_management:
return evt.reply(f"`{evt.command}` is a restricted command:"
+ "you may only run it in management rooms.")
elif needs_auth and not evt.sender.logged_in:
return evt.reply("This command requires you to be logged in.")
elif needs_admin and not evt.sender.is_admin:
return evt.reply("This is command requires administrator privileges.")
return func(evt)
command_handlers[name or func.__name__.replace("_", "-")] = wrapper
return wrapper
return decorator
class CommandEvent:
def __init__(self, handler, room, sender, command, args, is_management, is_portal):
self.az = handler.az
self.log = handler.log
self.loop = handler.loop
self.command_prefix = handler.command_prefix
self.room_id = room
self.sender = sender
self.command = command
self.args = args
self.is_management = is_management
self.is_portal = is_portal
def reply(self, message, allow_html=False, render_markdown=True):
message = message.replace("$cmdprefix+sp ",
"" if self.is_management else f"{self.command_prefix} ")
message = message.replace("$cmdprefix", self.command_prefix)
html = None
if render_markdown:
html = markdown.markdown(message, safe_mode="escape" if allow_html else False)
elif allow_html:
html = message
return self.az.intent.send_notice(self.room_id, message, html=html)
def format_duration(seconds):
def pluralize(count, singular): return singular if count == 1 else singular + "s"
def include(count, word): return f"{count} {pluralize(count, word)}" if count > 0 else ""
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
days, hours = divmod(hours, 24)
parts = [a for a in [
include(days, "day"),
include(hours, "hour"),
include(minutes, "minute"),
include(seconds, "second")] if a]
if len(parts) > 2:
return "{} and {}".format(", ".join(parts[:-1]), parts[-1])
return " and ".join(parts)
class CommandHandler:
log = logging.getLogger("mau.commands")
def __init__(self, context):
self.az, self.db, self.config, self.loop = context
self.command_prefix = self.config["bridge.command_prefix"]
# region Utility functions for handling commands
async def handle(self, room, sender, command, args, is_management, is_portal):
evt = CommandEvent(self, room, sender, command, args,
is_management, is_portal)
command = command.lower()
try:
command = command_handlers[command]
except KeyError:
if sender.command_status and "next" in sender.command_status:
args.insert(0, command)
evt.command = ""
command = sender.command_status["next"]
else:
command = command_handlers["unknown_command"]
try:
await command(evt)
except FloodWaitError as e:
return evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
except Exception:
self.log.exception(f"Fatal error handling command "
+ f"{evt.command} {' '.join(args)} from {sender.mxid}")
return evt.reply("Fatal error while handling command. Check logs for more details.")
+76
View File
@@ -0,0 +1,76 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from . import command_handler
@command_handler()
def cancel(evt):
if evt.sender.command_status:
action = evt.sender.command_status["action"]
evt.sender.command_status = None
return evt.reply(f"{action} cancelled.")
else:
return evt.reply("No ongoing command.")
@command_handler()
def unknown_command(evt):
return evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
@command_handler()
def help(evt):
if evt.is_management:
management_status = ("This is a management room: prefixing commands "
"with `$cmdprefix` is not required.\n")
elif evt.is_portal:
management_status = ("**This is a portal room**: you must always "
"prefix commands with `$cmdprefix`.\n"
"Management commands will not be sent to Telegram.")
else:
management_status = ("**This is not a management room**: you must "
"prefix commands with `$cmdprefix`.\n")
help = """\n
#### Generic bridge commands
**help** - Show this help message.
**cancel** - Cancel an ongoing action (such as login).
#### Authentication
**login** <_phone_> - Request an authentication code.
**logout** - Log out from Telegram.
**ping** - Check if you're logged into Telegram.
#### Initiating chats
**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users.
**pm** <_identifier_> - Open a private chat with the given Telegram user. The
identifier is either the internal user ID, the username or
the phone number.
**join** <_link_> - Join a chat with an invite link.
**create** [_type_] - Create a Telegram chat of the given type for the current
Matrix room. The type is either `group`, `supergroup` or
`channel` (defaults to `group`).
#### Portal management
**upgrade** - Upgrade a normal Telegram group to a supergroup.
**invite-link** - Get a Telegram invite link to the current chat.
**delete-portal** - Forget the current portal room. Only works for group chats; to delete
a private chat portal, simply leave the room.
**group-name** <_name_|`-`> - Change the username of a supergroup/channel. To disable, use a dash
(`-`) as the name.
**clean-rooms** - Clean up unused portal/management rooms.
"""
return evt.reply(management_status + help)
+261
View File
@@ -0,0 +1,261 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from telethon.errors import *
from telethon.tl.types import User as TLUser
from telethon.tl.functions.messages import ImportChatInviteRequest, CheckChatInviteRequest
from telethon.tl.functions.channels import JoinChannelRequest
from .. import puppet as pu, portal as po
from . import command_handler
@command_handler()
async def search(evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
force_remote = False
if evt.args[0] in {"-r", "--remote"}:
force_remote = True
evt.args.pop(0)
query = " ".join(evt.args)
if force_remote and len(query) < 5:
return await evt.reply("Minimum length of query for remote search is 5 characters.")
results, remote = await evt.sender.search(query, force_remote)
if not results:
if len(query) < 5 and remote:
return await evt.reply("No local results. "
"Minimum length of remote query is 5 characters.")
return await evt.reply("No results 3:")
reply = []
if remote:
reply += ["**Results from Telegram server:**", ""]
else:
reply += ["**Results in contacts:**", ""]
reply += [(f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
+ f"{puppet.id} ({similarity}% match)")
for puppet, similarity in results]
# TODO somehow show remote channel results when joining by alias is possible?
return await evt.reply("\n".join(reply))
@command_handler()
async def pm(evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
user = await evt.sender.client.get_entity(evt.args[0])
if not user:
return await evt.reply("User not found.")
elif not isinstance(user, TLUser):
return await evt.reply("That doesn't seem to be a user.")
portal = po.Portal.get_by_entity(user, evt.sender.tgid)
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
return await evt.reply(
f"Created private chat room with {pu.Puppet.get_displayname(user, False)}")
@command_handler()
async def invite_link(evt):
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
if portal.peer_type == "user":
return await evt.reply("You can't invite users to private chats.")
try:
link = await portal.get_invite_link(evt.sender)
return await evt.reply(f"Invite link to {portal.title}: {link}")
except ValueError as e:
return await evt.reply(e.args[0])
except ChatAdminRequiredError:
return await evt.reply("You don't have the permission to create an invite link.")
@command_handler(needs_admin=True)
async def delete_portal(evt):
room_id = evt.args[0] if len(evt.args) > 0 else evt.room_id
portal = po.Portal.get_by_mxid(room_id)
if not portal:
that_this = "This" if room_id == evt.room_id else "That"
return await evt.reply(f"{that_this} is not a portal room.")
async def post_confirm(_, confirm):
evt.sender.command_status = None
if len(confirm.args) > 0 and confirm.args[0] == "confirm-delete":
await portal.cleanup_and_delete()
if confirm.room_id != room_id:
return await confirm.reply("Portal successfully deleted.")
else:
return await confirm.reply("Portal deletion cancelled.")
evt.sender.command_status = {
"next": post_confirm,
"action": "Portal deletion",
}
return await evt.reply("Please confirm deletion of portal "
+ f"[{room_id}](https://matrix.to/#/{room_id}) "
+ f"to Telegram chat \"{portal.title}\" "
+ "by typing `$cmdprefix+sp confirm-delete`")
@command_handler()
async def join(evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)")
arg = regex.match(evt.args[0])
if not arg:
return await evt.reply("That doesn't look like a Telegram invite link.")
arg = arg.group(1)
if arg.startswith("joinchat/"):
invite_hash = arg[len("joinchat/"):]
try:
await evt.sender.client(CheckChatInviteRequest(invite_hash))
except InviteHashInvalidError:
return await evt.reply("Invalid invite link.")
except InviteHashExpiredError:
return await evt.reply("Invite link expired.")
try:
updates = evt.sender.client(ImportChatInviteRequest(invite_hash))
except UserAlreadyParticipantError:
return await evt.reply("You are already in that chat.")
else:
channel = await evt.sender.client.get_entity(arg)
if not channel:
return await evt.reply("Channel/supergroup not found.")
updates = await evt.sender.client(JoinChannelRequest(channel))
for chat in updates.chats:
portal = po.Portal.get_by_entity(chat)
if portal.mxid:
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
return await evt.reply(f"Created room for {portal.title}")
else:
await portal.invite_matrix([evt.sender.mxid])
return await evt.reply(f"Invited you to portal of {portal.title}")
@command_handler()
async def create(evt):
type = evt.args[0] if len(evt.args) > 0 else "group"
if type not in {"chat", "group", "supergroup", "channel"}:
return await evt.reply(
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`")
if po.Portal.get_by_mxid(evt.room_id):
return await evt.reply("This is already a portal room.")
state = await evt.az.intent.get_room_state(evt.room_id)
title = None
about = None
levels = None
for event in state:
if event["type"] == "m.room.name":
title = event["content"]["name"]
elif event["type"] == "m.room.topic":
about = event["content"]["topic"]
elif event["type"] == "m.room.power_levels":
levels = event["content"]
if not title:
return await evt.reply("Please set a title before creating a Telegram chat.")
elif (not levels or not levels["users"] or evt.az.intent.mxid not in levels["users"] or
levels["users"][evt.az.intent.mxid] < 100):
return await evt.reply(f"Please give "
+ f"[the bridge bot](https://matrix.to/#/{evt.az.intent.mxid})"
+ f" a power level of 100 before creating a Telegram chat.")
else:
for user, level in levels["users"].items():
if level >= 100 and user != evt.az.intent.mxid:
return await evt.reply(
f"Please make sure only the bridge bot has power level above"
+ f"99 before creating a Telegram chat.\n\n"
+ f"Use power level 95 instead of 100 for admins.")
supergroup = type == "supergroup"
type = {
"supergroup": "channel",
"channel": "channel",
"chat": "chat",
"group": "chat",
}[type]
portal = po.Portal(tgid=None, mxid=evt.room_id, title=title, about=about, peer_type=type)
try:
await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
except ValueError as e:
return await evt.reply(e.args[0])
return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
@command_handler()
async def upgrade(evt):
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type == "channel":
return await evt.reply("This is already a supergroup or a channel.")
elif portal.peer_type == "user":
return await evt.reply("You can't upgrade private chats.")
try:
await portal.upgrade_telegram_chat(evt.sender)
return await evt.reply(f"Group upgraded to supergroup. New ID: {portal.tgid}")
except ChatAdminRequiredError:
return await evt.reply("You don't have the permission to upgrade this group.")
except ValueError as e:
return await evt.reply(e.args[0])
@command_handler()
async def group_name(evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type != "channel":
return await evt.reply("Only channels and supergroups have usernames.")
try:
await portal.set_telegram_username(evt.sender,
evt.args[0] if evt.args[0] != "-" else "")
if portal.username:
return await evt.reply(f"Username of channel changed to {portal.username}.")
else:
return await evt.reply(f"Channel is now private.")
except ChatAdminRequiredError:
return await evt.reply(
"You don't have the permission to set the username of this channel.")
except UsernameNotModifiedError:
if portal.username:
return await evt.reply("That is already the username of this channel.")
else:
return await evt.reply("This channel is already private")
except UsernameOccupiedError:
return await evt.reply("That username is already in use.")
except UsernameInvalidError:
return await evt.reply("Invalid username")
+18 -2
View File
@@ -14,7 +14,7 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from sqlalchemy import Column, UniqueConstraint, ForeignKey, Integer, String
from sqlalchemy import Column, UniqueConstraint, ForeignKey, ForeignKeyConstraint, Integer, String
from sqlalchemy.orm import relationship
from .base import Base
@@ -51,6 +51,18 @@ class Message(Base):
__table_args__ = (UniqueConstraint('mxid', 'mx_room', 'tg_space', name='_mx_id_room'),)
class UserPortal(Base):
query = None
__tablename__ = "user_portal"
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True)
portal = Column(Integer, primary_key=True)
portal_receiver = Column(Integer, primary_key=True)
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver")),)
class User(Base):
query = None
__tablename__ = "user"
@@ -59,7 +71,10 @@ class User(Base):
tgid = Column(Integer, nullable=True)
tg_username = Column(String, nullable=True)
saved_contacts = Column(Integer, default=0)
contacts = relationship("Contact", uselist=True)
contacts = relationship("Contact", uselist=True,
cascade="save-update, merge, delete, delete-orphan")
portals = relationship("Portal", secondary="user_portal", single_parent=True,
cascade="save-update, merge, delete, delete-orphan")
class Contact(Base):
@@ -83,5 +98,6 @@ class Puppet(Base):
def init(db_session):
Portal.query = db_session.query_property()
Message.query = db_session.query_property()
UserPortal.query = db_session.query_property()
User.query = db_session.query_property()
Puppet.query = db_session.query_property()
+6 -3
View File
@@ -44,6 +44,7 @@ log = logging.getLogger("mau.formatter")
# everything up.
TEMP_ENC = "utf-16-le"
# region Matrix to Telegram
class MessageEntityReply(MessageEntityUnknown):
@@ -214,7 +215,7 @@ def matrix_to_telegram(html, tg_space=None):
# region Telegram to Matrix
async def telegram_event_to_matrix(evt, source, native_replies=False, message_link_in_reply=False,
main_intent=None):
main_intent=None, reply_text="Reply"):
text = evt.message
html = telegram_to_matrix(evt.message, evt.entities) if evt.entities else None
@@ -247,6 +248,8 @@ async def telegram_event_to_matrix(evt, source, native_replies=False, message_li
if msg:
if native_replies:
quote = f"<a href=\"https://matrix.to/#/{msg.mx_room}/{msg.mxid}\">Quote<br></a>"
if reply_text == "Edit":
html = "<u>Edit:</u> " + (html or escape(text))
else:
try:
event = await main_intent.get_event(msg.mx_room, msg.mxid)
@@ -259,11 +262,11 @@ async def telegram_event_to_matrix(evt, source, native_replies=False, message_li
displayname = puppet.displayname if puppet else sender
reply_to_user = (f"<a href='https://matrix.to/#/{sender}'>{displayname}</a>")
reply_to_msg = (("<a href='https://matrix.to/#/"
+ f"{msg.mx_room}/{msg.mxid}'>Reply</a>")
+ f"{msg.mx_room}/{msg.mxid}'>{reply_text}</a>")
if message_link_in_reply else "Reply")
quote = f"{reply_to_msg} to {reply_to_user}<blockquote>{body}</blockquote>"
except (ValueError, KeyError, MatrixRequestError):
quote = "Reply to unknown user <em>(Failed to fetch message)</em>:<br/>"
quote = "{reply_text} to unknown user <em>(Failed to fetch message)</em>:<br/>"
if html:
html = quote + html
else:
+1
View File
@@ -80,6 +80,7 @@ class MatrixHandler:
pass
portal.mxid = room
portal.save()
inviter.register_portal(portal)
await puppet.intent.send_notice(room, "Portal to private chat created.")
else:
await puppet.intent.join_room(room)
+63 -4
View File
@@ -30,6 +30,7 @@ from telethon.tl.functions.messages import *
from telethon.tl.functions.channels import *
from telethon.errors.rpc_error_list import *
from telethon.tl.types import *
from mautrix_appservice import MatrixRequestError, IntentError
from .db import Portal as DBPortal, Message as DBMessage
from . import puppet as p, user as u, formatter
@@ -204,7 +205,7 @@ class Portal:
if alias:
# TODO properly handle existing room aliases
intent.remove_room_alias(alias)
await intent.remove_room_alias(alias)
room = await intent.create_room(alias=alias, is_public=public, invitees=invites or [],
name=self.title, is_direct=direct)
if not room:
@@ -213,6 +214,7 @@ class Portal:
self.mxid = room["room_id"]
self.by_mxid[self.mxid] = self
self.save()
user.register_portal(self)
power_level_requirement = 0 if self.peer_type == "chat" and entity.admins_enabled else 50
levels = await self.main_intent.get_power_levels(self.mxid)
@@ -245,6 +247,7 @@ class Portal:
user = u.User.get_by_tgid(user_id)
if user:
user.register_portal(self)
await self.main_intent.invite(self.mxid, user.mxid)
async def delete_telegram_user(self, user_id, kick_message=None):
@@ -255,6 +258,7 @@ class Portal:
else:
await puppet.intent.leave_room(self.mxid)
if user:
user.unregister_portal(self)
await self.main_intent.kick(self.mxid, user.mxid, kick_message or "Left Telegram chat")
async def update_info(self, user, entity=None):
@@ -356,6 +360,38 @@ class Portal:
return link.link
async def get_authenticated_matrix_users(self):
try:
members = await self.main_intent.get_room_members(self.mxid)
except MatrixRequestError:
return []
authenticated = []
for member in members:
if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid:
continue
user = u.User.get_by_mxid(member)
if user.has_full_access:
authenticated.append(user)
return authenticated
@staticmethod
async def cleanup_room(intent, room_id, type="Portal"):
try:
members = await intent.get_room_members(room_id)
except MatrixRequestError:
members = []
for user in members:
if user != intent.mxid:
try:
await intent.kick(room_id, user, f"{type} deleted.")
except (MatrixRequestError, IntentError):
pass
await intent.leave_room(room_id)
async def cleanup_and_delete(self):
await self.cleanup_room(self.main_intent, self.mxid)
self.delete()
# endregion
# region Matrix event handling
@@ -700,12 +736,34 @@ class Portal:
async def handle_telegram_text(self, source, sender, evt):
self.log.debug(f"Sending {evt.message} to {self.mxid} by {sender.id}")
text, html = await formatter.telegram_event_to_matrix(evt, source,
config["bridge.native_replies"],
config["bridge.link_in_reply"],
self.main_intent)
config["bridge.native_replies"],
config["bridge.link_in_reply"],
self.main_intent)
await sender.intent.set_typing(self.mxid, is_typing=False)
return await sender.intent.send_text(self.mxid, text, html=html)
async def handle_telegram_edit(self, source, sender, evt):
if not self.mxid:
return
elif not config["bridge.edits_as_replies"]:
self.log.debug("Edits as replies disabled, ignoring edit event...")
return
evt.reply_to_msg_id = evt.id
text, html = await formatter.telegram_event_to_matrix(evt, source,
config["bridge.native_replies"],
config["bridge.link_in_reply"],
self.main_intent, reply_text="Edit")
await sender.intent.set_typing(self.mxid, is_typing=False)
response = await sender.intent.send_text(self.mxid, text, html=html)
mxid = response["event_id"]
tg_space = self.tgid if self.peer_type == "channel" else source.tgid
msg = DBMessage.query.get((evt.id, tg_space))
msg.mxid = mxid
msg.mx_room = self.mxid
self.db.commit()
async def handle_telegram_message(self, source, sender, evt):
if not self.mxid:
await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False)
@@ -817,6 +875,7 @@ class Portal:
user_levels = levels["users"]
if user:
user.register_portal(self)
user_level_defined = user.mxid in user_levels
user_has_right_level = (user_levels[user.mxid] == new_level
if user_level_defined else new_level == 0)
+71 -17
View File
@@ -22,6 +22,7 @@ from telethon.tl.types import *
from telethon.tl.types.contacts import ContactsNotModified
from telethon.tl.types import User as TLUser
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
from mautrix_appservice import MatrixRequestError
from .db import User as DBUser, Message as DBMessage, Contact as DBContact
from .tgclient import MautrixTelegramClient
@@ -38,17 +39,20 @@ class User:
by_mxid = {}
by_tgid = {}
def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0):
def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0,
db_portals=None):
self.mxid = mxid
self.tgid = tgid
self.username = username
self.contacts = []
self.saved_contacts = saved_contacts
self.db_contacts = db_contacts
self.portals = {}
self.db_portals = db_portals
self.command_status = None
self.connected = False
self.client = None
self._init_client()
self.is_admin = self.mxid in config.get("bridge.admins", [])
@@ -82,6 +86,19 @@ class User:
else:
self.contacts = []
@property
def db_portals(self):
return [portal.to_db(merge=False) for _, portal in self.portals.items()]
@db_portals.setter
def db_portals(self, portals):
if portals:
self.portals = {(portal.tgid, portal.tg_receiver):
po.Portal.get_by_tgid(portal.tgid, portal.tg_receiver)
for portal in portals}
else:
self.portals = {}
def get_input_entity(self, user):
return user.client.get_input_entity(InputUser(user_id=self.tgid, access_hash=0))
@@ -90,7 +107,8 @@ class User:
def to_db(self):
return self.db.merge(
DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username,
contacts=self.db_contacts, saved_contacts=self.saved_contacts))
contacts=self.db_contacts, saved_contacts=self.saved_contacts,
portals=self.db_portals))
def save(self):
self.to_db()
@@ -99,12 +117,12 @@ class User:
@classmethod
def from_db(cls, db_user):
return User(db_user.mxid, db_user.tgid, db_user.tg_username, db_user.contacts,
db_user.saved_contacts)
db_user.saved_contacts, db_user.portals)
# endregion
# region Telegram connection management
async def start(self):
def _init_client(self):
device = f"{platform.system()} {platform.release()}"
sysversion = MautrixTelegramClient.__version__
self.client = MautrixTelegramClient(self.mxid,
@@ -115,6 +133,8 @@ class User:
system_version=sysversion,
device_model=device)
self.client.add_update_handler(self.update_catch)
async def start(self):
self.connected = await self.client.connect()
if self.logged_in:
asyncio.ensure_future(self.post_login(), loop=self.loop)
@@ -149,7 +169,14 @@ class User:
self.save()
async def log_out(self):
self.connected = False
for _, portal in self.portals.items():
try:
await portal.main_intent.kick(portal.mxid, self.mxid, "Logged out of Telegram.")
except MatrixRequestError:
pass
self.portals = {}
self.contacts = []
self.save()
if self.tgid:
try:
del self.by_tgid[self.tgid]
@@ -157,8 +184,12 @@ class User:
pass
self.tgid = None
self.save()
await self.client.log_out()
# TODO kick user from portals
ok = await self.client.log_out()
if not ok:
return False
self._init_client()
await self.start()
return True
def _search_local(self, query, max_results=5, min_similarity=45):
results = []
@@ -201,9 +232,27 @@ class User:
if invalid:
continue
portal = po.Portal.get_by_entity(entity)
self.portals[portal.tgid_full] = portal
creators.append(portal.create_matrix_room(self, entity, invites=[self.mxid]))
self.save()
await asyncio.gather(*creators, loop=self.loop)
def register_portal(self, portal):
try:
if self.portals[portal.tgid_full] == portal:
return
except KeyError:
pass
self.portals[portal.tgid_full] = portal
self.save()
def unregister_portal(self, portal):
try:
del self.portals[portal.tgid_full]
self.save()
except KeyError:
pass
def _hash_contacts(self):
acc = 0
for id in sorted([self.saved_contacts] + [contact.id for contact in self.contacts]):
@@ -233,8 +282,8 @@ class User:
self.log.exception("Failed to handle Telegram update")
async def update(self, update):
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewMessage,
UpdateNewChannelMessage)):
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
await self.update_message(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)):
await self.update_typing(update)
@@ -317,7 +366,8 @@ class User:
elif isinstance(update, UpdateShortMessage):
portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user")
sender = pu.Puppet.get(self.tgid if update.out else update.user_id)
elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage,
UpdateEditMessage, UpdateEditChannelMessage)):
update = update.message
if isinstance(update.to_id, PeerUser) and not update.out:
portal = po.Portal.get_by_tgid(update.from_id, peer_type="user",
@@ -331,8 +381,8 @@ class User:
return update, None, None
return update, sender, portal
async def update_message(self, update):
update, sender, portal = self.get_message_details(update)
def update_message(self, original_update):
update, sender, portal = self.get_message_details(original_update)
if isinstance(update, MessageService):
if isinstance(update.action, MessageActionChannelMigrateFrom):
@@ -341,10 +391,14 @@ class User:
return
self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log,
sender.id)
await portal.handle_telegram_action(self, sender, update.action)
else:
self.log.debug("Handling message %s to %s by %d", update, portal.tgid_log, sender.tgid)
await portal.handle_telegram_message(self, sender, update)
return portal.handle_telegram_action(self, sender, update.action)
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
self.log.debug("Handling edit %s to %s by %d", update, portal.tgid_log, sender.tgid)
return portal.handle_telegram_edit(self, sender, update)
self.log.debug("Handling message %s to %s by %d", update, portal.tgid_log, sender.tgid)
return portal.handle_telegram_message(self, sender, update)
# endregion
# region Class instance lookup