Bridge group chat portal metadata from Matrix to Telegram. Fixes #33
WARNING: Portal table schema changed. Run the following SQL before updating:
ALTER TABLE portal ADD COLUMN about VARCHAR
This commit is contained in:
@@ -70,19 +70,20 @@ The bridge does not do this automatically.
|
||||
* [x] Images
|
||||
* [x] Files
|
||||
* [x] Message redactions
|
||||
* [ ] Presence (may not be possible)
|
||||
* [ ] Typing notifications (may not be possible)
|
||||
* [ ] † Presence
|
||||
* [ ] † Typing notifications
|
||||
* [ ] Pinning messages
|
||||
* [ ] Power level
|
||||
* [x] Normal chats
|
||||
* [ ] Supergroups/channels
|
||||
* [ ] Supergroups/channels (currently only creator level bridged)
|
||||
* [ ] Membership actions
|
||||
* [x] Inviting puppets
|
||||
* [ ] Inviting Matrix users who have logged in to Telegram
|
||||
* [x] Kicking
|
||||
* [ ] Joining (once room aliases have been implemented)
|
||||
* [x] Leaving
|
||||
* [ ] Room metadata changes
|
||||
* [x] Room metadata changes (name, topic, avatar)
|
||||
* [x] Initial room metadata
|
||||
* Telegram → Matrix
|
||||
* [x] Plaintext messages
|
||||
* [x] Formatted messages
|
||||
@@ -108,9 +109,12 @@ The bridge does not do this automatically.
|
||||
* [x] Inviting
|
||||
* [x] Kicking
|
||||
* [x] Joining/leaving
|
||||
* [x] Chat metadata changes
|
||||
* [ ] Public channel username changes
|
||||
* [x] Initial chat metadata
|
||||
* [ ] Chat metadata changes
|
||||
* [x] Title
|
||||
* [x] Avatar
|
||||
* [ ] † About text
|
||||
* [ ] † Public channel username
|
||||
* [x] Initial chat metadata (about text missing)
|
||||
* [x] Supergroup upgrade
|
||||
* Misc
|
||||
* [x] Automatic portal creation
|
||||
@@ -131,5 +135,9 @@ The bridge does not do this automatically.
|
||||
* [x] Joining chats with invite links (`join`)
|
||||
* [x] Creating a Telegram chat for an existing Matrix room (`create`)
|
||||
* [ ] Upgrading the chat of a portal room into a supergroup (`upgrade`)
|
||||
* [ ] Change public/private status of supergroup/channel (`setpublic`)
|
||||
* [ ] 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`)
|
||||
|
||||
† Information not automatically sent from source, i.e. implementation may not be possible
|
||||
|
||||
@@ -237,7 +237,7 @@ class CommandHandler:
|
||||
self.reply(f"Created private chat room with {pu.Puppet.get_displayname(user, False)}")
|
||||
|
||||
@command_handler
|
||||
def invite_link(self, sender, args):
|
||||
def invitelink(self, sender, args):
|
||||
if not sender.logged_in:
|
||||
return self.reply("This command requires you to be logged in.")
|
||||
|
||||
@@ -257,7 +257,7 @@ class CommandHandler:
|
||||
return self.reply("You don't have the permission to create an invite link.")
|
||||
|
||||
@command_handler
|
||||
def delete_portal(self, sender, args):
|
||||
def deleteportal(self, sender, args):
|
||||
if not sender.logged_in:
|
||||
return self.reply("This command requires you to be logged in.")
|
||||
elif not sender.is_admin:
|
||||
@@ -331,10 +331,13 @@ class CommandHandler:
|
||||
|
||||
state = self.az.intent.get_room_state(self._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:
|
||||
@@ -359,7 +362,7 @@ class CommandHandler:
|
||||
"group": "chat",
|
||||
}[type]
|
||||
|
||||
portal = po.Portal(tgid=None, mxid=self._room_id, title=title, peer_type=type)
|
||||
portal = po.Portal(tgid=None, mxid=self._room_id, title=title, about=about, peer_type=type)
|
||||
try:
|
||||
portal.create_telegram_chat(sender, supergroup=supergroup)
|
||||
except ValueError as e:
|
||||
@@ -370,6 +373,14 @@ class CommandHandler:
|
||||
def upgrade(self, sender, args):
|
||||
self.reply("Not yet implemented.")
|
||||
|
||||
@command_handler
|
||||
def setpublic(self, sender, args):
|
||||
self.reply("Not yet implemented.")
|
||||
|
||||
@command_handler
|
||||
def groupname(self, sender, args):
|
||||
self.reply("Not yet implemented.")
|
||||
|
||||
# endregion
|
||||
# region Command-related commands
|
||||
@command_handler
|
||||
@@ -416,9 +427,12 @@ _**Telegram actions**: commands for using the bridge to interact with Telegram._
|
||||
**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`).
|
||||
**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.
|
||||
**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.
|
||||
**setpublic** <_yes/no_> - Change whether or not a supergroup/channel is public.
|
||||
**groupname** <_name_> - Change the username of a supergroup/channel.
|
||||
To disable, use `setpublic no`.
|
||||
"""
|
||||
return self.reply(management_status + help)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class Portal(Base):
|
||||
# Telegram chat metadata
|
||||
username = Column(String, nullable=True)
|
||||
title = Column(String, nullable=True)
|
||||
about = Column(String, nullable=True)
|
||||
photo_id = Column(String, nullable=True)
|
||||
|
||||
|
||||
|
||||
@@ -184,6 +184,20 @@ class MatrixHandler:
|
||||
sender = User.get_by_mxid(sender)
|
||||
portal.handle_matrix_power_levels(sender, new["users"], old["users"])
|
||||
|
||||
def handle_room_meta(self, type, room, sender, content):
|
||||
portal = Portal.get_by_mxid(room)
|
||||
sender = User.get_by_mxid(sender)
|
||||
if sender.has_full_access and portal:
|
||||
handler, content_key = {
|
||||
"m.room.name": (portal.handle_matrix_title, "name"),
|
||||
"m.room.topic": (portal.handle_matrix_about, "topic"),
|
||||
"m.room.avatar": (portal.handle_matrix_avatar, "url"),
|
||||
}[type]
|
||||
if content_key not in content:
|
||||
# FIXME handle
|
||||
pass
|
||||
handler(sender, content[content_key])
|
||||
|
||||
def filter_matrix_event(self, event):
|
||||
return (event["sender"] == self.az.bot_mxid
|
||||
or Puppet.get_id_from_mxid(event["sender"]) is not None)
|
||||
@@ -209,3 +223,5 @@ class MatrixHandler:
|
||||
elif type == "m.room.power_levels":
|
||||
self.handle_power_levels(evt["room_id"], evt["sender"], evt["content"],
|
||||
evt["prev_content"])
|
||||
elif type == "m.room.name" or type == "m.room.avatar" or type == "m.room.topic":
|
||||
self.handle_room_meta(type, evt["room_id"], evt["sender"], evt["content"])
|
||||
|
||||
+95
-34
@@ -14,13 +14,9 @@
|
||||
#
|
||||
# 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.tl.functions.messages import (GetFullChatRequest, EditChatAdminRequest,
|
||||
CreateChatRequest, AddChatUserRequest,
|
||||
ExportChatInviteRequest, DeleteChatUserRequest)
|
||||
from telethon.tl.functions.channels import (GetParticipantsRequest, CreateChannelRequest,
|
||||
InviteToChannelRequest, ExportInviteRequest,
|
||||
LeaveChannelRequest, EditBannedRequest)
|
||||
from telethon.errors.rpc_error_list import ChatAdminRequiredError, LocationInvalidError
|
||||
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 PIL import Image
|
||||
from io import BytesIO
|
||||
@@ -43,13 +39,14 @@ class Portal:
|
||||
by_tgid = {}
|
||||
|
||||
def __init__(self, tgid, peer_type, tg_receiver=None, mxid=None, username=None, title=None,
|
||||
photo_id=None):
|
||||
about=None, photo_id=None):
|
||||
self.mxid = mxid
|
||||
self.tgid = tgid
|
||||
self.tg_receiver = tg_receiver or tgid
|
||||
self.peer_type = peer_type
|
||||
self.username = username
|
||||
self.title = title
|
||||
self.about = about
|
||||
self.photo_id = photo_id
|
||||
self._main_intent = None
|
||||
|
||||
@@ -77,6 +74,9 @@ class Portal:
|
||||
elif self.peer_type == "channel":
|
||||
return PeerChannel(channel_id=self.tgid)
|
||||
|
||||
def get_input_entity(self, user):
|
||||
return user.client.get_input_entity(self.peer)
|
||||
|
||||
# region Matrix room info updating
|
||||
|
||||
@property
|
||||
@@ -202,35 +202,49 @@ class Portal:
|
||||
changed = False
|
||||
|
||||
if self.peer_type == "channel":
|
||||
if self.username != entity.username:
|
||||
# TODO fix aliases and enable
|
||||
# if self.username:
|
||||
# self.main_intent.remove_room_alias(self._get_room_alias())
|
||||
self.username = entity.username
|
||||
# if self.username:
|
||||
# self.main_intent.add_room_alias(self.mxid, self._get_room_alias())
|
||||
changed = True
|
||||
changed = self.update_username(entity.username) or changed
|
||||
# TODO update about text
|
||||
# changed = self.update_about(entity.about) or changed
|
||||
|
||||
changed = self.update_title(entity.title, self.main_intent) or changed
|
||||
changed = self.update_title(entity.title) or changed
|
||||
|
||||
if isinstance(entity.photo, ChatPhoto):
|
||||
changed = self.update_avatar(user, entity.photo.photo_big, self.main_intent) or changed
|
||||
changed = self.update_avatar(user, entity.photo.photo_big) or changed
|
||||
|
||||
if changed:
|
||||
self.save()
|
||||
|
||||
def update_title(self, title, intent=None):
|
||||
def update_username(self, username):
|
||||
if self.username != username:
|
||||
# TODO fix aliases and enable
|
||||
# if self.username:
|
||||
# self.main_intent.remove_room_alias(self._get_room_alias())
|
||||
self.username = username
|
||||
# if self.username:
|
||||
# self.main_intent.add_room_alias(self.mxid, self._get_room_alias())
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_about(self, about):
|
||||
if self.about != about:
|
||||
self.about = about
|
||||
self.main_intent.set_room_topic(self.mxid, self.about)
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_title(self, title):
|
||||
if self.title != title:
|
||||
self.title = title
|
||||
self.main_intent.set_room_name(self.mxid, self.title)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_largest_photo_size(self, photo):
|
||||
@staticmethod
|
||||
def _get_largest_photo_size(photo):
|
||||
return max(photo.sizes, key=(lambda photo: (
|
||||
len(photo.bytes) if isinstance(photo, PhotoCachedSize) else photo.size)))
|
||||
|
||||
def update_avatar(self, user, photo, intent=None):
|
||||
def update_avatar(self, user, photo):
|
||||
photo_id = f"{photo.volume_id}-{photo.local_id}"
|
||||
if self.photo_id != photo_id:
|
||||
try:
|
||||
@@ -265,7 +279,7 @@ class Portal:
|
||||
link = user.client(ExportChatInviteRequest(chat_id=self.tgid))
|
||||
elif self.peer_type == "channel":
|
||||
link = user.client(
|
||||
ExportInviteRequest(channel=user.client.get_input_entity(self.peer)))
|
||||
ExportInviteRequest(channel=self.get_input_entity(user)))
|
||||
else:
|
||||
raise ValueError(f"Invalid peer type '{self.peer_type}' for invite link.")
|
||||
|
||||
@@ -296,11 +310,11 @@ class Portal:
|
||||
del self.by_tgid[self.tgid_full]
|
||||
del self.by_mxid[self.mxid]
|
||||
elif source:
|
||||
target = source.client.get_input_entity(PeerUser(user_id=user.tgid))
|
||||
target = user.get_input_entity(source)
|
||||
if self.peer_type == "chat":
|
||||
source.client(DeleteChatUserRequest(chat_id=self.tgid, user_id=target))
|
||||
else:
|
||||
channel = source.client.get_input_entity(self.peer)
|
||||
channel = self.get_input_entity(source)
|
||||
rights = ChannelBannedRights(datetime.fromtimestamp(0), True)
|
||||
source.client(EditBannedRequest(channel=channel,
|
||||
user_id=target,
|
||||
@@ -308,7 +322,7 @@ class Portal:
|
||||
elif self.peer_type == "chat":
|
||||
user.client(DeleteChatUserRequest(chat_id=self.tgid, user_id=InputUserSelf()))
|
||||
elif self.peer_type == "channel":
|
||||
channel = user.client.get_input_entity(self.peer)
|
||||
channel = self.get_input_entity(user)
|
||||
user.client(LeaveChannelRequest(channel=channel))
|
||||
|
||||
def handle_matrix_message(self, sender, message, event_id):
|
||||
@@ -358,7 +372,7 @@ class Portal:
|
||||
|
||||
def handle_matrix_power_levels(self, sender, new_users, old_users):
|
||||
for user, level in new_users.items():
|
||||
user_id = p.Puppet.get_id_by_mxid(user)
|
||||
user_id = p.Puppet.get_id_from_mxid(user)
|
||||
if not user_id:
|
||||
mx_user = u.User.get_by_mxid(user, create=False)
|
||||
if not mx_user or not mx_user.tgid:
|
||||
@@ -368,6 +382,52 @@ class Portal:
|
||||
sender.client(
|
||||
EditChatAdminRequest(chat_id=self.tgid, user_id=user_id, is_admin=level >= 50))
|
||||
|
||||
def handle_matrix_about(self, sender, about):
|
||||
if self.peer_type not in {"channel"}:
|
||||
return
|
||||
channel = self.get_input_entity(sender)
|
||||
sender.client(EditAboutRequest(channel=channel, about=about))
|
||||
self.about = about
|
||||
self.save()
|
||||
|
||||
def handle_matrix_title(self, sender, title):
|
||||
if self.peer_type not in {"chat", "channel"}:
|
||||
return
|
||||
|
||||
if self.peer_type == "chat":
|
||||
sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title))
|
||||
else:
|
||||
channel = self.get_input_entity(sender)
|
||||
sender.client(EditTitleRequest(channel=channel, title=title))
|
||||
self.title = title
|
||||
self.save()
|
||||
|
||||
def handle_matrix_avatar(self, sender, url):
|
||||
if self.peer_type not in {"chat", "channel"}:
|
||||
# Invalid peer type
|
||||
return
|
||||
|
||||
file = self.main_intent.download_file(url)
|
||||
mime = magic.from_buffer(file, mime=True)
|
||||
ext = mimetypes.guess_extension(mime)
|
||||
uploaded = sender.client.upload_file(file, file_name=f"avatar{ext}")
|
||||
photo = InputChatUploadedPhoto(file=uploaded)
|
||||
|
||||
if self.peer_type == "chat":
|
||||
updates = sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo))
|
||||
else:
|
||||
channel = self.get_input_entity(sender)
|
||||
updates = sender.client(EditPhotoRequest(channel=channel, photo=photo))
|
||||
for update in updates.updates:
|
||||
is_photo_update = (isinstance(update, UpdateNewMessage)
|
||||
and isinstance(update.message, MessageService)
|
||||
and isinstance(update.message.action, MessageActionChatEditPhoto))
|
||||
if is_photo_update:
|
||||
loc = self._get_largest_photo_size(update.message.action.photo).location
|
||||
self.photo_id = f"{loc.volume_id}-{loc.local_id}"
|
||||
self.save()
|
||||
break
|
||||
|
||||
# endregion
|
||||
# region Telegram chat info updating
|
||||
|
||||
@@ -402,7 +462,7 @@ class Portal:
|
||||
updates = source.client(CreateChatRequest(title=self.title, users=invites))
|
||||
entity = updates.chats[0]
|
||||
elif self.peer_type == "channel":
|
||||
updates = source.client(CreateChannelRequest(title=self.title, about="",
|
||||
updates = source.client(CreateChannelRequest(title=self.title, about=self.about or "",
|
||||
megagroup=supergroup))
|
||||
entity = updates.chats[0]
|
||||
source.client(InviteToChannelRequest(channel=source.client.get_input_entity(entity),
|
||||
@@ -419,7 +479,7 @@ class Portal:
|
||||
if self.peer_type == "chat":
|
||||
source.client(AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0))
|
||||
elif self.peer_type == "channel":
|
||||
target = source.client.get_input_entity(PeerUser(user_id=puppet.tgid))
|
||||
target = puppet.get_input_entity(source)
|
||||
source.client(InviteToChannelRequest(channel=self.peer, users=[target]))
|
||||
else:
|
||||
raise ValueError("Invalid peer type for Telegram user invite")
|
||||
@@ -432,7 +492,7 @@ class Portal:
|
||||
user.intent.set_typing(self.mxid, is_typing=True)
|
||||
|
||||
def handle_telegram_photo(self, source, sender, media):
|
||||
largest_size = self.get_largest_photo_size(media.photo)
|
||||
largest_size = self._get_largest_photo_size(media.photo)
|
||||
file = source.download_file(largest_size.location)
|
||||
mime_type = magic.from_buffer(file, mime=True)
|
||||
uploaded = sender.intent.upload_file(file, mime_type)
|
||||
@@ -554,12 +614,13 @@ class Portal:
|
||||
if not isinstance(action, create_and_continue):
|
||||
return
|
||||
|
||||
# TODO figure out how to see changes to about text / channel username
|
||||
if isinstance(action, MessageActionChatEditTitle):
|
||||
if self.update_title(action.title, self.main_intent):
|
||||
if self.update_title(action.title):
|
||||
self.save()
|
||||
elif isinstance(action, MessageActionChatEditPhoto):
|
||||
largest_size = self.get_largest_photo_size(action.photo)
|
||||
if self.update_avatar(source, largest_size.location, self.main_intent):
|
||||
largest_size = self._get_largest_photo_size(action.photo)
|
||||
if self.update_avatar(source, largest_size.location):
|
||||
self.save()
|
||||
elif isinstance(action, MessageActionChatAddUser):
|
||||
for user_id in action.users:
|
||||
@@ -619,7 +680,7 @@ class Portal:
|
||||
return self.db.merge(
|
||||
DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type,
|
||||
mxid=self.mxid, username=self.username, title=self.title,
|
||||
photo_id=self.photo_id))
|
||||
about=self.about, photo_id=self.photo_id))
|
||||
|
||||
def migrate_and_save(self, new_id):
|
||||
existing = DBPortal.query.get(self.tgid_full)
|
||||
@@ -643,7 +704,7 @@ class Portal:
|
||||
return Portal(tgid=db_portal.tgid, tg_receiver=db_portal.tg_receiver,
|
||||
peer_type=db_portal.peer_type, mxid=db_portal.mxid,
|
||||
username=db_portal.username, title=db_portal.title,
|
||||
photo_id=db_portal.photo_id)
|
||||
about=db_portal.about, photo_id=db_portal.photo_id)
|
||||
|
||||
# endregion
|
||||
# region Class instance lookup
|
||||
|
||||
@@ -15,7 +15,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/>.
|
||||
import re
|
||||
from telethon.tl.types import UserProfilePhoto
|
||||
from telethon.tl.types import UserProfilePhoto, PeerUser
|
||||
from telethon.errors.rpc_error_list import LocationInvalidError
|
||||
from .db import Puppet as DBPuppet
|
||||
|
||||
@@ -47,6 +47,9 @@ class Puppet:
|
||||
def tgid(self):
|
||||
return self.id
|
||||
|
||||
def get_input_entity(self, user):
|
||||
return user.client.get_input_entity(PeerUser(user_id=self.tgid))
|
||||
|
||||
def to_db(self):
|
||||
return self.db.merge(
|
||||
DBPuppet(id=self.id, username=self.username, displayname=self.displayname,
|
||||
|
||||
@@ -61,6 +61,9 @@ class User:
|
||||
def has_full_access(self):
|
||||
return self.logged_in and self.whitelisted
|
||||
|
||||
def get_input_entity(self, user):
|
||||
return user.client.get_input_entity(InputUser(user_id=self.tgid))
|
||||
|
||||
# region Database conversion
|
||||
|
||||
def to_db(self):
|
||||
|
||||
Reference in New Issue
Block a user