Add support for inviting users and initiating chats from Matrix

This commit is contained in:
Tulir Asokan
2018-01-28 17:02:32 +02:00
parent aadaffa97a
commit 143ca11f05
9 changed files with 208 additions and 39 deletions
+3 -3
View File
@@ -72,7 +72,7 @@ does not do this automatically.~~
* [ ] Pinning messages * [ ] Pinning messages
* [x] Power level * [x] Power level
* [ ] Membership actions * [ ] Membership actions
* [ ] Inviting * [x] Inviting
* [ ] Kicking * [ ] Kicking
* [ ] Joining/leaving * [ ] Joining/leaving
* [ ] Room metadata changes * [ ] Room metadata changes
@@ -108,7 +108,7 @@ does not do this automatically.~~
* Misc * Misc
* [x] Automatic portal creation for groups/channels at startup * [x] Automatic portal creation for groups/channels at startup
* [x] Automatic portal creation for groups/channels when receiving invite/message * [x] Automatic portal creation for groups/channels when receiving invite/message
* [ ] Private chat creation by inviting Telegram user to new room * [x] Private chat creation by inviting Telegram user to new room
* [ ] Use optional bot to relay messages for unauthenticated Matrix users * [ ] Use optional bot to relay messages for unauthenticated Matrix users
* [ ] Joining public channels/supergroups using room aliases * [ ] Joining public channels/supergroups using room aliases
* Commands * Commands
@@ -117,5 +117,5 @@ does not do this automatically.~~
* [x] Searching for users (`search`) * [x] Searching for users (`search`)
* [x] Starting private chats (`pm`) * [x] Starting private chats (`pm`)
* [x] Joining chats with invite links (`join`) * [x] Joining chats with invite links (`join`)
* [ ] Creating a Telegram chat for an existing Matrix room (`create`) * [x] Creating a Telegram chat for an existing Matrix room (`create`)
* [ ] Upgrading the chat of a portal room into a supergroup (`upgrade`) * [ ] Upgrading the chat of a portal room into a supergroup (`upgrade`)
+1 -1
View File
@@ -21,7 +21,7 @@ appservice:
id: telegram id: telegram
# Username of the appservice bot. # Username of the appservice bot.
bot_username: telegrambot bot_username: telegrambot
bot_displayname: Mautrix-Telegram Bridge bot_displayname: Telegram bridge bot
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
as_token: "This value is generated when generating the registration" as_token: "This value is generated when generating the registration"
+1 -1
View File
@@ -44,7 +44,7 @@ class StateStore:
return self._set_membership(room, user, "join") return self._set_membership(room, user, "join")
def invited(self, room, user): def invited(self, room, user):
return self._set_membership(room, user, "invited") return self._set_membership(room, user, "invite")
def left(self, room, user): def left(self, room, user):
return self._set_membership(room, user, "left") return self._set_membership(room, user, "left")
+17 -6
View File
@@ -196,7 +196,7 @@ class IntentAPI:
self._ensure_joined(room_id) self._ensure_joined(room_id)
try: try:
response = self.client.invite_user(room_id, user_id) response = self.client.invite_user(room_id, user_id)
self.state_store.set_invited(room_id, user_id) self.state_store.invited(room_id, user_id)
return response return response
except MatrixRequestError as e: except MatrixRequestError as e:
if matrix_error_code(e) != "M_FORBIDDEN": if matrix_error_code(e) != "M_FORBIDDEN":
@@ -249,6 +249,8 @@ class IntentAPI:
def send_text(self, room_id, text, html=None, type="m.text"): def send_text(self, room_id, text, html=None, type="m.text"):
if html: if html:
if not text:
text = html
return self.send_message(room_id, { return self.send_message(room_id, {
"body": text, "body": text,
"msgtype": type, "msgtype": type,
@@ -264,9 +266,14 @@ class IntentAPI:
def send_message(self, room_id, body): def send_message(self, room_id, body):
return self.send_event(room_id, "m.room.message", body) return self.send_event(room_id, "m.room.message", body)
def error_and_leave(self, room_id, text, html=None):
self._ensure_joined(room_id)
self.send_notice(room_id, text, html=html)
self.leave_room(room_id)
def kick(self, room_id, user_id, message): def kick(self, room_id, user_id, message):
self._ensure_joined(room_id) self._ensure_joined(room_id)
self.client.kick_user(room_id, user_id, message) return self.client.kick_user(room_id, user_id, message)
def send_event(self, room_id, type, body, txn_id=None): def send_event(self, room_id, type, body, txn_id=None):
self._ensure_joined(room_id) self._ensure_joined(room_id)
@@ -285,13 +292,17 @@ class IntentAPI:
self.state_store.left(room_id, self.mxid) self.state_store.left(room_id, self.mxid)
return self.client.leave_room(room_id) return self.client.leave_room(room_id)
def get_room_members(self, room_id): def get_room_memberships(self, room_id):
return self.client.get_room_members(room_id) return self.client.get_room_members(room_id)
def get_joined_users(self, room_id): def get_room_members(self, room_id, allowed_memberships=("join",)):
memberships = self.get_room_members(room_id) memberships = self.get_room_memberships(room_id)
return [membership["state_key"] for membership in memberships["chunk"] if return [membership["state_key"] for membership in memberships["chunk"] if
membership["content"]["membership"] == "join"] membership["content"]["membership"] in allowed_memberships]
def get_room_state(self, room_id):
self._ensure_joined(room_id)
return self.client.get_room_state(room_id)
# endregion # endregion
# region Ensure functions # region Ensure functions
+49 -5
View File
@@ -267,11 +267,56 @@ class CommandHandler:
updates = sender.client(JoinChannelRequest(channel)) updates = sender.client(JoinChannelRequest(channel))
for chat in updates.chats: for chat in updates.chats:
portal = po.Portal.get_by_entity(chat) portal = po.Portal.get_by_entity(chat)
portal.create_room(sender, chat, [sender.mxid]) portal.create_matrix_room(sender, chat, [sender.mxid])
self.reply(f"Created room for {portal.title}")
@command_handler @command_handler
def create(self, sender, args): def create(self, sender, args):
self.reply("Not yet implemented.") type = args[0] if len(args) > 0 else "group"
if type not in {"chat", "group", "supergroup", "channel"}:
return self.reply("**Usage:** `$cmdprefix+sp create [`group`/`supergroup`/`channel`]")
elif not sender.tgid:
return self.reply("This command requires you to be logged in.")
if po.Portal.get_by_mxid(self._room_id):
return self.reply("This is already a portal room.")
state = self.az.intent.get_room_state(self._room_id)
title = None
levels = None
for event in state:
if event["type"] == "m.room.name":
title = event["content"]["name"]
elif event["type"] == "m.room.power_levels":
levels = event["content"]
if not title:
return self.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 self.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 self.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"
types = {
"supergroup": "channel",
"channel": "channel",
"chat": "chat",
"group": "chat",
}
portal = po.Portal(tgid=None, mxid=self._room_id, title=title, peer_type=types[type])
try:
portal.create_telegram_chat(sender, supergroup=supergroup)
except ValueError as e:
return self.reply(e.args[0])
self.reply(f"Telegram chat created. ID: {portal.tgid}")
@command_handler @command_handler
def upgrade(self, sender, args): def upgrade(self, sender, args):
@@ -320,9 +365,8 @@ _**Telegram actions**: commands for using the bridge to interact with Telegram._
**pm** <_identifier_> - Open a private chat with the given Telegram user. The identifier is either **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. the internal user ID, the username or the phone number.
**join** <_link_> - Join a chat with an invite link. **join** <_link_> - Join a chat with an invite link.
**create** <_group/channel_> [_room ID_] - Create a Telegram chat of the given type for a Matrix **create** [_type_] - Create a Telegram chat of the given type for the current Matrix room.
room. If the room ID is not specified, a chat for the The type is either `group`, `supergroup` or `channel` (defaults to `group`).
current room is created.
**upgrade** - Upgrade a normal Telegram group to a supergroup. **upgrade** - Upgrade a normal Telegram group to a supergroup.
""" """
return self.reply(management_status + help) return self.reply(management_status + help)
+69 -15
View File
@@ -13,10 +13,11 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import re from matrix_client.errors import MatrixRequestError
from .user import User from .user import User
from .portal import Portal from .portal import Portal
from .puppet import Puppet
from .commands import CommandHandler from .commands import CommandHandler
@@ -26,32 +27,83 @@ class MatrixHandler:
self.log = log.getChild("mx") self.log = log.getChild("mx")
self.commands = CommandHandler(context) self.commands = CommandHandler(context)
alias_format = self.config.get("bridge.alias_template", "telegram_{groupname}").format(groupname="(.+)")
hs = self.config["homeserver"]["domain"]
self.localpart_regex = re.compile(f"@{alias_format}:{hs}")
self.az.matrix_event_handler(self.handle_event) self.az.matrix_event_handler(self.handle_event)
self.az.intent.set_display_name( self.az.intent.set_display_name(
self.config.get("appservice.bot_displayname", "Mautrix-Telegram Bridge")) self.config.get("appservice.bot_displayname", "Telegram bridge bot"))
def is_puppet(self, mxid): def is_puppet(self, mxid):
match = self.localpart_regex.match(mxid) match = Puppet.mxid_regex.match(mxid)
return True if match else False return True if match else False
def get_puppet(self, mxid):
match = Puppet.mxid_regex.match(mxid)
if not match:
return None
return Puppet.get(int(match.group(1)))
def handle_puppet_invite(self, room, puppet, inviter):
self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room}")
portal = Portal.get_by_mxid(room)
if portal:
if portal.peer_type == "user":
puppet.intent.error_and_leave(
room, text="You can not invite additional users to private chats.")
return
portal.invite_telegram(inviter, puppet)
puppet.intent.join_room(room)
return
try:
members = self.az.intent.get_room_members(room)
except MatrixRequestError:
members = []
if self.az.intent.mxid not in members:
if len(members) > 1:
puppet.intent.error_and_leave(room, text=None, html=(
f"Please invite "
f"<a href='https://matrix.to/#/{self.az.intent.mxid}'>the bridge bot</a> "
f"first if you want to create a Telegram chat."))
return
puppet.intent.join_room(room)
existing_portal = Portal.get_by_tgid(puppet.tgid, "user")
if existing_portal:
try:
puppet.intent.invite(existing_portal.mxid, inviter.mxid)
puppet.intent.send_notice(room, text=None, html=(
"You already have a private chat with me: "
f"<a href='https://matrix.to/#/{existing_portal.mxid}'>"
"Link to room"
"</a>"))
puppet.intent.leave_room(room)
return
except MatrixRequestError:
existing_portal.delete()
portal = Portal(tgid=puppet.tgid, peer_type="user", mxid=room)
portal.save()
puppet.intent.send_notice(room, "Portal to private chat created.")
else:
puppet.intent.join_room(room)
puppet.intent.send_notice(room, "This puppet will remain inactive until a Telegram "
"chat is created for this room.")
def handle_invite(self, room, user, inviter): def handle_invite(self, room, user, inviter):
inviter = User.get_by_mxid(inviter)
if user == self.az.bot_mxid: if user == self.az.bot_mxid:
self.az.intent.join_room(room) self.az.intent.join_room(room)
return return
tgid = self.get_puppet(user) elif not inviter.tgid:
if tgid: return
# TODO handle puppet invite puppet = self.get_puppet(user)
self.log.debug(f"{inviter} invited puppet for {tgid} to {room}") if puppet:
self.handle_puppet_invite(room, puppet, inviter)
return return
# These can probably be ignored # These can probably be ignored
self.log.debug(f"{inviter} invited {user} to {room}") self.log.debug(f"{inviter} invited {user} to {room}")
def handle_part(self, room, user): def handle_part(self, room, user):
self.log.debug(f"{user} left {room}") self.log.debug(f"{user} left {room}")
user = User.get_by_mxid(user, create=False)
def is_command(self, message): def is_command(self, message):
text = message.get("body", "") text = message.get("body", "")
@@ -68,14 +120,14 @@ class MatrixHandler:
sender = User.get_by_mxid(sender) sender = User.get_by_mxid(sender)
portal = Portal.get_by_mxid(room) portal = Portal.get_by_mxid(room)
if portal and not is_command: if sender.tgid and portal and not is_command:
portal.handle_matrix_message(sender, message, event_id) portal.handle_matrix_message(sender, message, event_id)
return return
if message["msgtype"] != "m.text": if message["msgtype"] != "m.text":
return return
is_management = len(self.az.intent.get_joined_users(room)) == 2 is_management = len(self.az.intent.get_room_members(room)) == 2
if is_command or is_management: if is_command or is_management:
try: try:
command, arguments = text.split(" ", 1) command, arguments = text.split(" ", 1)
@@ -90,12 +142,13 @@ class MatrixHandler:
def handle_redaction(self, room, sender, event_id): def handle_redaction(self, room, sender, event_id):
portal = Portal.get_by_mxid(room) portal = Portal.get_by_mxid(room)
sender = User.get_by_mxid(sender) sender = User.get_by_mxid(sender)
if portal: if sender.tgid and portal:
portal.handle_matrix_deletion(sender, event_id) portal.handle_matrix_deletion(sender, event_id)
def handle_power_levels(self, room, sender, new, old): def handle_power_levels(self, room, sender, new, old):
portal = Portal.get_by_mxid(room) portal = Portal.get_by_mxid(room)
if portal: sender = User.get_by_mxid(sender)
if sender.tgid and portal:
sender = User.get_by_mxid(sender) sender = User.get_by_mxid(sender)
portal.handle_matrix_power_levels(sender, new["users"], old["users"]) portal.handle_matrix_power_levels(sender, new["users"], old["users"])
@@ -115,6 +168,7 @@ class MatrixHandler:
elif membership == "leave": elif membership == "leave":
self.handle_part(evt["room_id"], evt["state_key"]) self.handle_part(evt["room_id"], evt["state_key"])
elif membership == "join": elif membership == "join":
# TODO handle when needed
pass pass
elif type == "m.room.message": elif type == "m.room.message":
self.handle_message(evt["room_id"], evt["sender"], content, evt["event_id"]) self.handle_message(evt["room_id"], evt["sender"], content, evt["event_id"])
+66 -7
View File
@@ -13,8 +13,10 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from telethon.tl.functions.messages import GetFullChatRequest, EditChatAdminRequest from telethon.tl.functions.messages import (GetFullChatRequest, EditChatAdminRequest,
from telethon.tl.functions.channels import GetParticipantsRequest CreateChatRequest, AddChatUserRequest)
from telethon.tl.functions.channels import (GetParticipantsRequest, CreateChannelRequest,
InviteToChannelRequest)
from telethon.errors.rpc_error_list import ChatAdminRequiredError from telethon.errors.rpc_error_list import ChatAdminRequiredError
from telethon.tl.types import * from telethon.tl.types import *
from PIL import Image from PIL import Image
@@ -43,7 +45,8 @@ class Portal:
self.photo_id = photo_id self.photo_id = photo_id
self._main_intent = None self._main_intent = None
self.by_tgid[tgid] = self if tgid:
self.by_tgid[tgid] = self
if mxid: if mxid:
self.by_mxid[mxid] = self self.by_mxid[mxid] = self
@@ -73,7 +76,7 @@ class Portal:
for user in users: for user in users:
self.main_intent.invite(self.mxid, user) self.main_intent.invite(self.mxid, user)
def create_room(self, user, entity=None, invites=[], update_if_exists=True): def create_matrix_room(self, user, entity=None, invites=[], update_if_exists=True):
if not entity: if not entity:
entity = user.client.get_entity(self.peer) entity = user.client.get_entity(self.peer)
self.log.debug("Fetched data: %s", entity) self.log.debug("Fetched data: %s", entity)
@@ -277,6 +280,59 @@ class Portal:
sender.client( sender.client(
EditChatAdminRequest(chat_id=self.tgid, user_id=user_id, is_admin=level >= 50)) EditChatAdminRequest(chat_id=self.tgid, user_id=user_id, is_admin=level >= 50))
# endregion
# region Telegram chat info updating
def _get_telegram_users_in_matrix_room(self):
user_tgids = set()
user_mxids = self.main_intent.get_room_members(self.mxid, ("join", "invite"))
for user in user_mxids:
if user == self.az.intent.mxid:
continue
mx_user = u.User.get_by_mxid(user, create=False)
if mx_user and mx_user.tgid:
user_tgids.add(mx_user.tgid)
puppet_match = p.Puppet.mxid_regex.match(user)
if puppet_match:
user_tgids.add(int(puppet_match.group(1)))
return user_tgids
def create_telegram_chat(self, source, supergroup=False):
if not self.mxid:
raise ValueError("Can't create Telegram chat for portal without Matrix room.")
elif self.tgid:
raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.")
invites = self._get_telegram_users_in_matrix_room()
if len(invites) < 2:
# TODO when we get the option for a bot, this won't happen when the bot is activated.
raise ValueError("Not enough Telegram users to create a chat")
invites = [source.client.get_input_entity(id) for id in invites]
if self.peer_type == "chat":
updates = source.client(CreateChatRequest(title=self.title, users=invites))
elif self.peer_type == "channel":
updates = source.client(CreateChannelRequest(title=self.title, megagroup=supergroup))
# TODO invite people
else:
raise ValueError("Invalid peer type for Telegram chat creation")
entity = updates.chats[0]
self.tgid = entity.id
self.update_info(source, entity)
self.save()
def invite_telegram(self, source, puppet):
if self.peer_type == "chat":
source.client(AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0))
elif self.peer_type == "channel":
source.client(InviteToChannelRequest(channel=self.peer,
users=[InputUser(user_id=puppet.tgid)],
fwd_limit=0))
else:
raise ValueError("Invalid peer type for Telegram user invite")
# endregion # endregion
# region Telegram event handling # region Telegram event handling
@@ -342,7 +398,7 @@ class Portal:
type = "m.image" type = "m.image"
sender.intent.set_typing(self.mxid, is_typing=False) sender.intent.set_typing(self.mxid, is_typing=False)
return sender.intent.send_file(self.mxid, uploaded["content_uri"], info=info, text=name, return sender.intent.send_file(self.mxid, uploaded["content_uri"], info=info, text=name,
type=type) type=type)
def handle_telegram_location(self, source, sender, location): def handle_telegram_location(self, source, sender, location):
long = location.long long = location.long
@@ -376,7 +432,7 @@ class Portal:
def handle_telegram_message(self, source, sender, evt): def handle_telegram_message(self, source, sender, evt):
if not self.mxid: if not self.mxid:
self.create_room(source, invites=[source.mxid]) self.create_matrix_room(source, invites=[source.mxid])
if evt.message: if evt.message:
response = self.handle_telegram_text(source, sender, evt) response = self.handle_telegram_text(source, sender, evt)
@@ -403,7 +459,7 @@ class Portal:
create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate) create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate)
create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink) create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink)
if isinstance(action, create_and_exit + create_and_continue): if isinstance(action, create_and_exit + create_and_continue):
self.create_room(source, invites=[source.mxid]) self.create_matrix_room(source, invites=[source.mxid])
if not isinstance(action, create_and_continue): if not isinstance(action, create_and_continue):
return return
@@ -486,6 +542,9 @@ class Portal:
self.to_db() self.to_db()
self.db.commit() self.db.commit()
def delete(self):
self.db.delete(self.to_db())
@classmethod @classmethod
def from_db(cls, db_portal): def from_db(cls, db_portal):
return Portal(db_portal.tgid, db_portal.peer_type, db_portal.mxid, db_portal.username, return Portal(db_portal.tgid, db_portal.peer_type, db_portal.mxid, db_portal.username,
+1
View File
@@ -24,6 +24,7 @@ class Puppet:
log = None log = None
db = None db = None
az = None az = None
mxid_regex = None
cache = {} cache = {}
def __init__(self, id=None, username=None, displayname=None, photo_id=None): def __init__(self, id=None, username=None, displayname=None, photo_id=None):
+1 -1
View File
@@ -173,7 +173,7 @@ class User:
or isinstance(entity, (ChannelForbidden, ChatForbidden))): or isinstance(entity, (ChannelForbidden, ChatForbidden))):
continue continue
portal = po.Portal.get_by_entity(entity) portal = po.Portal.get_by_entity(entity)
portal.create_room(self, entity, invites=[self.mxid]) portal.create_matrix_room(self, entity, invites=[self.mxid])
# endregion # endregion
# region Telegram update handling # region Telegram update handling