diff --git a/README.md b/README.md
index 023f39e9..3108ff18 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,8 @@ does not do this automatically.
* [x] Formatted messages
* [ ] Bot commands (!command -> /command)
* [x] Mentions
- * [ ] Locations
+ * [ ] Rich quotes
+ * [ ] Locations (not implemented in Riot)
* [ ] Images
* [ ] Files
* [ ] Message redactions
@@ -76,6 +77,8 @@ does not do this automatically.
* [x] Formatted messages
* [x] Bot commands (/command -> !command)
* [x] Mentions
+ * [ ] Replies
+ * [ ] Forwards
* [ ] Images
* [ ] Locations
* [ ] Stickers
@@ -91,8 +94,10 @@ does not do this automatically.
* [ ] Inviting
* [ ] Kicking
* [ ] Joining/leaving
- * [ ] Chat metadata changes
- * [ ] Initial chat metadata
+ * [x] Chat metadata changes
+ * [ ] Public channel username changes
+ * [x] Initial chat metadata
+ * [ ] Supergroup upgrade
* [ ] Message edits
* Initiating chats
* [x] Automatic portal creation for groups/channels at startup
@@ -104,6 +109,4 @@ does not do this automatically.
* [ ] Creating Telegram chats for existing Matrix rooms
* Misc
* [ ] Use optional bot to relay messages for unauthenticated Matrix users
- * [ ] Properly handle upgrading groups to supergroups
- * [ ] Allow upgrading group to supergroup from Matrix
- * [ ] Handle public channel username changes
+ * [ ] Command to upgrade chat to supergroup from Matrix
diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py
index 035f8261..99f628ff 100644
--- a/mautrix_appservice/intent_api.py
+++ b/mautrix_appservice/intent_api.py
@@ -15,6 +15,7 @@
# along with this program. If not, see .
import re
import json
+import magic
from matrix_client.api import MatrixHttpApi
from matrix_client.errors import MatrixRequestError
@@ -44,12 +45,14 @@ class HTTPAPI(MatrixHttpApi):
def intent(self, user):
return IntentAPI(user, self.user(user), self, log=self.log)
- def _send(self, method, path, content=None, query_params={}, headers={}):
+ def _send(self, method, path, content=None, query_params={}, headers={},
+ api_path="/_matrix/client/r0"):
if not query_params:
query_params = {}
query_params["user_id"] = self.identity
- self.log.debug("%s %s %s", method, path, content)
- return super()._send(method, path, content, query_params, headers)
+ log_content = content if not isinstance(content, bytes) else f"<{len(content)} bytes>"
+ self.log.debug("%s %s %s", method, path, log_content)
+ return super()._send(method, path, content, query_params, headers, api_path=api_path)
def create_room(self, alias=None, is_public=False, name=None, topic=None, is_direct=False,
invitees=()):
@@ -157,6 +160,11 @@ class IntentAPI:
self._ensure_registered()
return self.client.set_presence(status)
+ def media_upload(self, photo_data, mime_type=None):
+ self._ensure_registered()
+ mime_type = mime_type or magic.from_buffer(photo_data, mime=True)
+ return self.client.media_upload(photo_data, mime_type)
+
def set_typing(self, room_id, is_typing=True, timeout=5000):
self._ensure_joined(room_id)
return self.client.set_typing(room_id, is_typing, timeout)
@@ -166,6 +174,21 @@ class IntentAPI:
self._ensure_registered()
return self.client.create_room(alias, is_public, name, topic, is_direct, invitees)
+ def set_room_avatar(self, room_id, avatar_url, info=None):
+ content = {
+ "url": avatar_url,
+ }
+ if info:
+ content["info"] = info
+ self._ensure_joined(room_id)
+ self._ensure_has_power_level_for(room_id, "m.room.avatar")
+ return self.send_state_event(room_id, "m.room.avatar", content)
+
+ def set_room_name(self, room_id, name):
+ self._ensure_joined(room_id)
+ self._ensure_has_power_level_for(room_id, "m.room.name")
+ return self.client.set_room_name(room_id, name)
+
def send_text(self, room_id, text, html=None, notice=False):
if html:
return self.send_message(room_id, {
diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py
index d7cf1a22..a4dda1f2 100644
--- a/mautrix_telegram/db.py
+++ b/mautrix_telegram/db.py
@@ -23,10 +23,18 @@ from .base import Base
class Portal(Base):
__tablename__ = "portal"
+ # Telegram chat information
tgid = Column(Integer, primary_key=True)
peer_type = Column(String)
+
+ # Matrix portal information
mxid = Column(String, unique=True, nullable=True)
+ # Telegram chat metadata
+ username = Column(String, nullable=True)
+ title = Column(String, nullable=True)
+ photo_id = Column(String, nullable=True)
+
class User(Base):
__tablename__ = "user"
diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py
index 8162c81d..50d9160b 100644
--- a/mautrix_telegram/portal.py
+++ b/mautrix_telegram/portal.py
@@ -13,9 +13,11 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
+from io import BytesIO
+
from telethon.tl.functions.messages import GetFullChatRequest
from telethon.tl.functions.channels import GetParticipantsRequest
-from telethon.tl.types import ChannelParticipantsRecent, PeerChat, PeerChannel, PeerUser
+from telethon.tl.types import *
from .db import Portal as DBPortal
from . import puppet as p, formatter
@@ -26,25 +28,35 @@ class Portal:
by_mxid = {}
by_tgid = {}
- def __init__(self, tgid, peer_type, mxid=None):
+ def __init__(self, tgid, peer_type, mxid=None, username=None, title=None, photo_id=None):
self.mxid = mxid
self.tgid = tgid
self.peer_type = peer_type
+ self.username = username
+ self.title = title
+ self.photo_id = photo_id
self.by_tgid[tgid] = self
if mxid:
self.by_mxid[mxid] = self
- def create_room(self, user, entity=None, invites=[]):
+ def get_main_intent(self):
+ direct = self.peer_type == "user"
+ puppet = p.Puppet.get(self.tgid) if direct else None
+ return puppet.intent if direct else self.az.intent
+
+ def create_room(self, user, entity=None, invites=[], update_if_exists=True):
self.log.debug("Creating room for %d", self.tgid)
if not entity:
entity = user.client.get_entity(self.peer)
self.log.debug("Fetched data: %s", entity)
if self.mxid:
+ if update_if_exists:
+ self.update_info(user, entity)
+ users = self.get_users(user, entity)
+ self.sync_telegram_users(users)
self.invite_matrix(invites)
- users = self.get_users(user, entity)
- self.sync_telegram_users(users)
return self.mxid
try:
@@ -55,8 +67,9 @@ class Portal:
direct = self.peer_type == "user"
puppet = p.Puppet.get(self.tgid) if direct else None
intent = puppet.intent if direct else self.az.intent
+ # TODO set room alias if public channel.
room = intent.create_room(invitees=invites, name=title,
- is_direct=direct)
+ is_direct=direct)
if not room:
raise Exception(f"Failed to create room for {self.tgid}")
@@ -64,6 +77,7 @@ class Portal:
self.by_mxid[self.mxid] = self
self.save()
if not direct:
+ self.update_info(user, entity)
users = self.get_users(user, entity)
self.sync_telegram_users(users)
else:
@@ -76,6 +90,33 @@ class Portal:
user.update_info(entity)
user.intent.join_room(self.mxid)
+ def update_info(self, user, entity=None):
+ if self.peer_type == "user":
+ self.log.warn("Called update_info() for direct chat portal %d", self.tgid)
+ return
+
+ self.log.debug("Updating info of %d", self.tgid)
+ if not entity:
+ entity = user.client.get_entity(self.peer)
+ self.log.debug("Fetched data: %s", entity)
+ changed = False
+
+ intent = self.get_main_intent()
+
+ if self.peer_type == "channel":
+ if self.username != entity.username:
+ # TODO update room alias
+ self.username = entity.username
+ changed = True
+
+ changed = self.update_title(entity.title, intent) or changed
+
+ if isinstance(entity.photo, ChatPhoto):
+ changed = self.update_avatar(user, entity.photo.photo_big, intent) or changed
+
+ if changed:
+ self.save()
+
def handle_matrix_message(self, sender, message):
type = message["msgtype"]
if type == "m.text":
@@ -97,6 +138,45 @@ class Portal:
else:
sender.intent.send_text(self.mxid, evt.message)
+ def update_title(self, title, intent=None):
+ if self.title != title:
+ self.title = title
+ intent = intent or self.get_main_intent()
+ intent.set_room_name(self.mxid, self.title)
+ return True
+ return False
+
+
+ def update_avatar(self, user, photo, intent=None):
+ photo_id = f"{photo.volume_id}-{photo.local_id}"
+ if self.photo_id != photo_id:
+ intent = intent or self.get_main_intent()
+
+ file = BytesIO()
+
+ user.client.download_file(
+ InputFileLocation(photo.volume_id, photo.local_id, photo.secret), file)
+
+ uploaded = intent.media_upload(file.getvalue())
+ intent.set_room_avatar(self.mxid, uploaded["content_uri"])
+
+ file.close()
+
+ self.photo_id = photo_id
+ return True
+ return False
+
+ def handle_telegram_action(self, source, sender, action):
+ intent = self.get_main_intent()
+ action_type = type(action)
+ if action_type == MessageActionChatEditTitle:
+ if self.update_title(action.title, intent):
+ self.save()
+ elif action_type == MessageActionChatEditPhoto:
+ largest_size = max(action.photo.sizes, key=lambda photo: photo.size)
+ if self.update_avatar(source, largest_size.location, intent):
+ self.save()
+
@property
def peer(self):
if self.peer_type == "user":
@@ -121,7 +201,9 @@ class Portal:
pass
def to_db(self):
- return self.db.merge(DBPortal(tgid=self.tgid, peer_type=self.peer_type, mxid=self.mxid))
+ return self.db.merge(DBPortal(tgid=self.tgid, peer_type=self.peer_type, mxid=self.mxid,
+ username=self.username, title=self.title,
+ photo_id=self.photo_id))
def save(self):
self.to_db()
@@ -129,7 +211,8 @@ class Portal:
@classmethod
def from_db(cls, db_portal):
- return Portal(db_portal.tgid, db_portal.peer_type, db_portal.mxid)
+ return Portal(db_portal.tgid, db_portal.peer_type, db_portal.mxid, db_portal.username,
+ db_portal.title, db_portal.photo_id)
@classmethod
def get_by_mxid(cls, mxid):
@@ -165,7 +248,28 @@ class Portal:
@classmethod
def get_by_entity(cls, entity):
- return cls.get_by_tgid(entity.id, entity.__class__.__name__.lower())
+ entity_type = type(entity)
+ if entity_type in {Chat, ChatFull}:
+ type_name = "chat"
+ id = entity.id
+ elif entity_type in {PeerChat, InputPeerChat}:
+ type_name = "chat"
+ id = entity.chat_id
+ elif entity_type in {Channel, ChannelFull}:
+ type_name = "channel"
+ id = entity.id
+ elif entity_type in {PeerChannel, InputPeerChannel, InputChannel}:
+ type_name = "channel"
+ id = entity.channel_id
+ elif entity_type in {User, UserFull}:
+ type_name = "user"
+ id = entity.id
+ elif entity_type in {PeerUser, InputPeerUser, InputUser}:
+ type_name = "user"
+ id = entity.user_id
+ else:
+ raise ValueError(f"Unknown entity type {entity_type.__name__}")
+ return cls.get_by_tgid(id, type_name)
def init(context):
diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py
index 734c26f9..3de59c08 100644
--- a/mautrix_telegram/puppet.py
+++ b/mautrix_telegram/puppet.py
@@ -50,7 +50,8 @@ class Puppet:
self.to_db()
self.db.commit()
- def get_displayname(self, info):
+ @staticmethod
+ def get_displayname(info):
if info.first_name or info.last_name:
name = " ".join([info.first_name or "", info.last_name or ""]).strip()
elif info.username:
diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py
index 35e94beb..2903d4fa 100644
--- a/mautrix_telegram/user.py
+++ b/mautrix_telegram/user.py
@@ -123,7 +123,6 @@ class User:
continue
portal = po.Portal.get_by_entity(entity)
portal.create_room(self, entity, invites=[self.mxid])
- # portal.update_info(self, entity)
def update_catch(self, update):
try:
@@ -153,15 +152,25 @@ class User:
elif isinstance(update.status, UserStatusOffline):
puppet.intent.set_presence("offline")
return
+ elif update_type == UpdateNewMessage or update_type == UpdateNewChannelMessage:
+ update = update.message
+ sender = pu.Puppet.get(update.from_id)
+ portal = po.Portal.get_by_entity(update.to_id)
else:
self.log.debug("Unhandled update: %s", update)
return
if not portal.mxid:
portal.create_room(self, invites=[self.mxid])
- self.log.debug("Handling message portal=%s sender=%s update=%s", portal, sender,
+
+ if isinstance(update, MessageService):
+ self.log.debug("Handling action portal=%s sender=%s action=%s", portal, sender,
+ update.action)
+ portal.handle_telegram_action(self, sender, update.action)
+ else:
+ self.log.debug("Handling message portal=%s sender=%s update=%s", portal, sender,
update)
- portal.handle_telegram_message(sender, update)
+ portal.handle_telegram_message(sender, update)
@classmethod
def get_by_mxid(cls, mxid, create=True):
diff --git a/requirements.txt b/requirements.txt
index bc946534..eb6c7eb1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,6 +2,7 @@ aiohttp
-e git+git://github.com/Cadair/matrix-python-sdk#egg=matrix_client
#matrix-client
ruamel.yaml
+python-magic
SQLAlchemy
Telethon
Markdown