diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f5b3534..ffab7e8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ a new user. * Added support for adding an optional random prefix to relayed user displaynames to help distinguish them on the Telegram side. +* Improved syncing profile info to room info when using encryption and/or the + `private_chat_profile_meta` config option. * Fixed bug in v0.11.0 that broke `!tg create`. # v0.11.1 (2021-01-10) diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py index e1d3f501..f367a671 100644 --- a/mautrix_telegram/abstract_user.py +++ b/mautrix_telegram/abstract_user.py @@ -469,9 +469,11 @@ class AbstractUser(ABC): puppet.username = update.username if await puppet.update_displayname(self, update): await puppet.save() + await puppet.update_portals_meta() elif isinstance(update, UpdateUserPhoto): if await puppet.update_avatar(self, update.photo): await puppet.save() + await puppet.update_portals_meta() else: self.log.warning(f"Unexpected other user info update: {type(update)}") diff --git a/mautrix_telegram/db/portal.py b/mautrix_telegram/db/portal.py index 622a8a66..f20c504b 100644 --- a/mautrix_telegram/db/portal.py +++ b/mautrix_telegram/db/portal.py @@ -54,6 +54,8 @@ class Portal: title: str | None about: str | None photo_id: str | None + name_set: bool + avatar_set: bool local_config: dict[str, Any] = attr.ib(factory=lambda: {}) @@ -67,7 +69,8 @@ class Portal: columns: ClassVar[str] = ( "tgid, tg_receiver, peer_type, megagroup, mxid, avatar_url, encrypted, sponsored_event_id," - "sponsored_event_ts, sponsored_msg_random_id, username, title, about, photo_id, config" + "sponsored_event_ts, sponsored_msg_random_id, username, title, about, photo_id, " + "name_set, avatar_set, config" ) @classmethod @@ -86,10 +89,15 @@ class Portal: return cls._from_row(await cls.db.fetchrow(q, username.lower())) @classmethod - async def find_private_chats(cls, tg_receiver: TelegramID) -> list[Portal]: + async def find_private_chats_of(cls, tg_receiver: TelegramID) -> list[Portal]: q = f"SELECT {cls.columns} FROM portal WHERE tg_receiver=$1 AND peer_type='user'" return [cls._from_row(row) for row in await cls.db.fetch(q, tg_receiver)] + @classmethod + async def find_private_chats_with(cls, tgid: TelegramID) -> list[Portal]: + q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND peer_type='user'" + return [cls._from_row(row) for row in await cls.db.fetch(q, tgid)] + @classmethod async def all(cls) -> list[Portal]: rows = await cls.db.fetch(f"SELECT {cls.columns} FROM portal") @@ -111,17 +119,20 @@ class Portal: self.title, self.about, self.photo_id, + self.name_set, + self.avatar_set, self.megagroup, json.dumps(self.local_config) if self.local_config else None, ) async def save(self) -> None: - q = ( - "UPDATE portal SET mxid=$4, avatar_url=$5, encrypted=$6, sponsored_event_id=$7," - " sponsored_event_ts=$8, sponsored_msg_random_id=$9, username=$10," - " title=$11, about=$12, photo_id=$13, megagroup=$14, config=$15 " - "WHERE tgid=$1 AND tg_receiver=$2 AND (peer_type=$3 OR true)" - ) + q = """ + UPDATE portal + SET mxid=$4, avatar_url=$5, encrypted=$6, sponsored_event_id=$7, sponsored_event_ts=$8, + sponsored_msg_random_id=$9, username=$10, title=$11, about=$12, photo_id=$13, + name_set=$14, avatar_set=$15, megagroup=$16, config=$17 + WHERE tgid=$1 AND tg_receiver=$2 AND (peer_type=$3 OR true) + """ await self.db.execute(q, *self._values) async def update_id(self, id: TelegramID, peer_type: str) -> None: @@ -135,12 +146,13 @@ class Portal: self.peer_type = peer_type async def insert(self) -> None: - q = ( - "INSERT INTO portal (tgid, tg_receiver, peer_type, mxid, avatar_url, encrypted," - " sponsored_event_id, sponsored_event_ts, sponsored_msg_random_id," - " username, title, about, photo_id, megagroup, config) " - "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)" - ) + q = """ + INSERT INTO portal ( + tgid, tg_receiver, peer_type, mxid, avatar_url, encrypted, + sponsored_event_id, sponsored_event_ts, sponsored_msg_random_id, + username, title, about, photo_id, name_set, avatar_set, megagroup, config + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + """ await self.db.execute(q, *self._values) async def delete(self) -> None: diff --git a/mautrix_telegram/db/puppet.py b/mautrix_telegram/db/puppet.py index 3963faf9..8fde9c66 100644 --- a/mautrix_telegram/db/puppet.py +++ b/mautrix_telegram/db/puppet.py @@ -21,7 +21,7 @@ from asyncpg import Record from attr import dataclass from yarl import URL -from mautrix.types import SyncToken, UserID +from mautrix.types import ContentURI, SyncToken, UserID from mautrix.util.async_db import Database from ..types import TelegramID @@ -44,6 +44,9 @@ class Puppet: disable_updates: bool username: str | None photo_id: str | None + avatar_url: ContentURI | None + name_set: bool + avatar_set: bool is_bot: bool | None is_channel: bool @@ -62,8 +65,8 @@ class Puppet: columns: ClassVar[str] = ( "id, is_registered, displayname, displayname_source, displayname_contact, " - "displayname_quality, disable_updates, username, photo_id, is_bot, is_channel, " - "custom_mxid, access_token, next_batch, base_url" + "displayname_quality, disable_updates, username, photo_id, avatar_url, " + "name_set, avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url" ) @classmethod @@ -103,6 +106,9 @@ class Puppet: self.disable_updates, self.username, self.photo_id, + self.avatar_url, + self.name_set, + self.avatar_set, self.is_bot, self.is_channel, self.custom_mxid, @@ -112,21 +118,22 @@ class Puppet: ) async def save(self) -> None: - q = ( - "UPDATE puppet " - "SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5," - " displayname_quality=$6, disable_updates=$7, username=$8, photo_id=$9, is_bot=$10," - " is_channel=$11, custom_mxid=$12, access_token=$13, next_batch=$14, base_url=$15 " - "WHERE id=$1" - ) + q = """ + UPDATE puppet + SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5, + displayname_quality=$6, disable_updates=$7, username=$8, photo_id=$9, + avatar_url=$10, name_set=$11, avatar_set=$12, is_bot=$13, is_channel=$14, + custom_mxid=$15, access_token=$16, next_batch=$17, base_url=$18 + WHERE id=$1 + """ await self.db.execute(q, *self._values) async def insert(self) -> None: - q = ( - "INSERT INTO puppet (" - " id, is_registered, displayname, displayname_source, displayname_contact," - " displayname_quality, disable_updates, username, photo_id, is_bot, is_channel," - " custom_mxid, access_token, next_batch, base_url" - ") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)" - ) + q = """ + INSERT INTO puppet ( + id, is_registered, displayname, displayname_source, displayname_contact, + displayname_quality, disable_updates, username, photo_id, avatar_url, name_set, + avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) + """ await self.db.execute(q, *self._values) diff --git a/mautrix_telegram/db/upgrade/__init__.py b/mautrix_telegram/db/upgrade/__init__.py index cab1770b..6ebc258b 100644 --- a/mautrix_telegram/db/upgrade/__init__.py +++ b/mautrix_telegram/db/upgrade/__init__.py @@ -8,4 +8,5 @@ from . import ( v03_reactions, v04_disappearing_messages, v05_channel_ghosts, + v06_puppet_avatar_url, ) diff --git a/mautrix_telegram/db/upgrade/v06_puppet_avatar_url.py b/mautrix_telegram/db/upgrade/v06_puppet_avatar_url.py new file mode 100644 index 00000000..24571fec --- /dev/null +++ b/mautrix_telegram/db/upgrade/v06_puppet_avatar_url.py @@ -0,0 +1,31 @@ +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2022 Tulir Asokan +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from asyncpg import Connection + +from . import upgrade_table + + +@upgrade_table.register(description="Store avatar mxc URI in puppet table") +async def upgrade_v6(conn: Connection) -> None: + await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_url TEXT") + await conn.execute("ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false") + await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false") + await conn.execute("UPDATE puppet SET name_set=true WHERE displayname<>''") + await conn.execute("UPDATE puppet SET avatar_set=true WHERE photo_id<>''") + await conn.execute("ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false") + await conn.execute("ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false") + await conn.execute("UPDATE portal SET name_set=true WHERE title<>'' AND mxid<>''") + await conn.execute("UPDATE portal SET avatar_set=true WHERE photo_id<>'' AND mxid<>''") diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index f3197c37..4d4b9707 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -147,6 +147,7 @@ from telethon.tl.types import ( TypePhotoSize, TypeUser, TypeUserFull, + TypeUserProfilePhoto, UpdateChannelUserTyping, UpdateChatUserTyping, UpdateNewMessage, @@ -305,6 +306,8 @@ class Portal(DBPortal, BasePortal): title: str | None = None, about: str | None = None, photo_id: str | None = None, + name_set: bool = False, + avatar_set: bool = False, local_config: dict[str, Any] | None = None, ) -> None: super().__init__( @@ -322,6 +325,8 @@ class Portal(DBPortal, BasePortal): title=title, about=about, photo_id=photo_id, + name_set=name_set, + avatar_set=avatar_set, local_config=local_config or {}, ) BasePortal.__init__(self) @@ -636,14 +641,7 @@ class Portal(DBPortal, BasePortal): puppet = await p.Puppet.get_by_tgid(self.tgid) await puppet.update_info(user, entity) await puppet.intent_for(self).join_room(self.mxid) - if self.encrypted or self.private_chat_portal_meta: - # The bridge bot needs to join for e2ee, but that messes up the default name - # generation. If/when canonical DMs happen, this might not be necessary anymore. - changed = await self._update_title(puppet.displayname) - changed = await self._update_avatar(user, entity.photo) or changed - if changed: - await self.save() - await self.update_bridge_info() + await self.update_info_from_puppet(puppet, user, entity.photo) puppet = await p.Puppet.get_by_custom_mxid(user.mxid) if puppet: @@ -657,6 +655,22 @@ class Portal(DBPortal, BasePortal): if self.sync_matrix_state: await self.main_intent.get_joined_members(self.mxid) + async def update_info_from_puppet( + self, + puppet: p.Puppet, + source: au.AbstractUser | None = None, + photo: UserProfilePhoto | None = None, + ) -> None: + if not self.encrypted and not self.private_chat_portal_meta: + return + # The bridge bot needs to join for e2ee, but that messes up the default name + # generation. If/when canonical DMs happen, this might not be necessary anymore. + changed = await self._update_avatar_from_puppet(puppet, source, photo) + changed = await self._update_title(puppet.displayname) or changed + if changed: + await self.save() + await self.update_bridge_info() + async def create_matrix_room( self, user: au.AbstractUser, @@ -802,8 +816,8 @@ class Portal(DBPortal, BasePortal): "state_key": self.bridge_info_state_key, "content": self.bridge_info, }, + # TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec { - # TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec "type": str(StateHalfShotBridge), "state_key": self.bridge_info_state_key, "content": self.bridge_info, @@ -814,7 +828,7 @@ class Portal(DBPortal, BasePortal): self.encrypted = True initial_state.append( { - "type": "m.room.encryption", + "type": str(EventType.ROOM_ENCRYPTION), "content": {"algorithm": "m.megolm.v1.aes-sha2"}, } ) @@ -822,6 +836,8 @@ class Portal(DBPortal, BasePortal): create_invites.append(self.az.bot_mxid) if direct and (self.encrypted or self.private_chat_portal_meta): self.title = puppet.displayname + self.avatar_url = puppet.avatar_url + self.photo_id = puppet.photo_id if self.config["appservice.community_id"]: initial_state.append( { @@ -832,6 +848,13 @@ class Portal(DBPortal, BasePortal): creation_content = {} if not self.config["bridge.federate_rooms"]: creation_content["m.federate"] = False + if self.avatar_url: + initial_state.append( + { + "type": str(EventType.ROOM_AVATAR), + "content": {"url": self.avatar_url}, + } + ) with self.backfill_lock: room_id = await self.main_intent.create_room( @@ -846,6 +869,8 @@ class Portal(DBPortal, BasePortal): ) if not room_id: raise Exception(f"Failed to create room") + self.name_set = bool(self.title) + self.avatar_set = bool(self.avatar_url) if self.encrypted and self.matrix.e2ee and direct: try: @@ -1106,21 +1131,48 @@ class Portal(DBPortal, BasePortal): async def _update_title( self, title: str, sender: p.Puppet | None = None, save: bool = False ) -> bool: - if self.title == title: + if self.title == title and self.name_set: return False self.title = title - await self._try_set_state( - sender, EventType.ROOM_NAME, RoomNameStateEventContent(name=self.title) - ) + try: + await self._try_set_state( + sender, EventType.ROOM_NAME, RoomNameStateEventContent(name=self.title) + ) + self.name_set = True + except Exception as e: + self.log.warning(f"Failed to set room name: {e}") + self.name_set = False if save: await self.save() return True + async def _update_avatar_from_puppet( + self, puppet: p.Puppet, user: au.AbstractUser | None, photo: UserProfilePhoto | None + ) -> bool: + if self.photo_id == puppet.photo_id and self.avatar_set: + return False + if puppet.avatar_url: + self.photo_id = puppet.photo_id + self.avatar_url = puppet.avatar_url + try: + await self._try_set_state( + None, EventType.ROOM_AVATAR, RoomAvatarStateEventContent(url=self.avatar_url) + ) + self.avatar_set = True + except Exception as e: + self.log.warning(f"Failed to set room avatar: {e}") + self.avatar_set = False + return True + elif photo is not None and user is not None: + return await self._update_avatar(user, photo=photo) + else: + return False + async def _update_avatar( self, user: au.AbstractUser, - photo: TypeChatPhoto, + photo: TypeChatPhoto | TypeUserProfilePhoto, sender: p.Puppet | None = None, save: bool = False, ) -> bool: @@ -1143,26 +1195,27 @@ class Portal(DBPortal, BasePortal): and not self.config["bridge.allow_avatar_remove"] ): return False - if self.photo_id != photo_id: + if self.photo_id != photo_id or not self.avatar_set: if not photo_id: - await self._try_set_state( - sender, EventType.ROOM_AVATAR, RoomAvatarStateEventContent(url=None) - ) self.photo_id = "" self.avatar_url = None - if save: - await self.save() - return True - file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc) - if file: - await self._try_set_state( - sender, EventType.ROOM_AVATAR, RoomAvatarStateEventContent(url=file.mxc) - ) + elif self.photo_id != photo_id or not self.avatar_url: + file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc) + if not file: + return False self.photo_id = photo_id self.avatar_url = file.mxc - if save: - await self.save() - return True + try: + await self._try_set_state( + sender, EventType.ROOM_AVATAR, RoomAvatarStateEventContent(url=self.avatar_url) + ) + self.avatar_set = True + except Exception as e: + self.log.warning(f"Failed to set room avatar: {e}") + self.avatar_set = False + if save: + await self.save() + return True return False # endregion @@ -2120,11 +2173,8 @@ class Portal(DBPortal, BasePortal): async def enable_dm_encryption(self) -> bool: ok = await super().enable_dm_encryption() if ok: - try: - puppet = await p.Puppet.get_by_tgid(self.tgid) - await self.main_intent.set_room_name(self.mxid, puppet.displayname) - except Exception: - self.log.warning(f"Failed to set room name", exc_info=True) + puppet = await p.Puppet.get_by_tgid(self.tgid) + await self.update_info_from_puppet(puppet) return ok # endregion @@ -3373,8 +3423,10 @@ class Portal(DBPortal, BasePortal): self.by_mxid[self.mxid] = self @classmethod - async def all(cls) -> AsyncGenerator[Portal, None]: - portals = await super().all() + async def _yield_portals( + cls, query: Awaitable[list[DBPortal]] + ) -> AsyncGenerator[Portal, None]: + portals = await query portal: cls for portal in portals: try: @@ -3384,15 +3436,16 @@ class Portal(DBPortal, BasePortal): yield portal @classmethod - async def find_private_chats(cls, tg_receiver: TelegramID) -> AsyncGenerator[Portal, None]: - portals = await super().find_private_chats(tg_receiver) - portal: cls - for portal in portals: - try: - yield cls.by_tgid[portal.tgid_full] - except KeyError: - await portal.postinit() - yield portal + def all(cls) -> AsyncGenerator[Portal, None]: + return cls._yield_portals(super().all()) + + @classmethod + def find_private_chats_of(cls, tg_receiver: TelegramID) -> AsyncGenerator[Portal, None]: + return cls._yield_portals(super().find_private_chats_of(tg_receiver)) + + @classmethod + def find_private_chats_with(cls, tgid: TelegramID) -> AsyncGenerator[Portal, None]: + return cls._yield_portals(super().find_private_chats_with(tgid)) @classmethod @async_getter_lock diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 3fb6006d..b65ab897 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -41,7 +41,6 @@ from yarl import URL from mautrix.appservice import IntentAPI from mautrix.bridge import BasePuppet, async_getter_lock -from mautrix.errors import MatrixError from mautrix.types import ContentURI, RoomID, SyncToken, UserID from mautrix.util.simple_template import SimpleTemplate @@ -74,6 +73,9 @@ class Puppet(DBPuppet, BasePuppet): disable_updates: bool = False, username: str | None = None, photo_id: str | None = None, + avatar_url: ContentURI | None = None, + name_set: bool = False, + avatar_set: bool = False, is_bot: bool = False, is_channel: bool = False, custom_mxid: UserID | None = None, @@ -91,6 +93,9 @@ class Puppet(DBPuppet, BasePuppet): disable_updates=disable_updates, username=username, photo_id=photo_id, + avatar_url=avatar_url, + name_set=name_set, + avatar_set=avatar_set, is_bot=is_bot, is_channel=is_channel, custom_mxid=custom_mxid, @@ -255,14 +260,28 @@ class Puppet(DBPuppet, BasePuppet): self.log.exception(f"Failed to update info from source {source.tgid}") if changed: + await self.update_portals_meta() await self.save() + async def update_portals_meta(self) -> None: + if not p.Portal.private_chat_portal_meta and not self.mx.e2ee: + return + async for portal in p.Portal.find_private_chats_with(self.tgid): + await portal.update_info_from_puppet(self) + async def update_displayname( self, source: au.AbstractUser, info: User | Channel | UpdateUserName ) -> bool: if self.disable_updates: return False - if source.is_relaybot or source.is_bot: + if ( + self.displayname + and self.displayname.startswith("Deleted user ") + and not getattr(info, "deleted", False) + ): + allow_because = "target user was previously deleted" + self.displayname_quality = 0 + elif source.is_relaybot or source.is_bot: allow_because = "source user is a bot" elif self.displayname_source == source.tgid: allow_because = "source user is the primary source" @@ -288,7 +307,9 @@ class Puppet(DBPuppet, BasePuppet): return False displayname, quality = self.get_displayname(info) - if displayname != self.displayname and quality >= self.displayname_quality: + needs_reset = displayname != self.displayname or not self.name_set + is_high_quality = quality >= self.displayname_quality + if needs_reset and is_high_quality: allow_because = f"{allow_because} and quality {quality} >= {self.displayname_quality}" self.log.debug( f"Updating displayname of {self.id} (src: {source.tgid}, allowed " @@ -302,11 +323,10 @@ class Puppet(DBPuppet, BasePuppet): await self.default_mxid_intent.set_displayname( displayname[: self.config["bridge.displayname_max_length"]] ) - except MatrixError: - self.log.exception("Failed to set displayname") - self.displayname = "" - self.displayname_source = None - self.displayname_quality = 0 + self.name_set = True + except Exception as e: + self.log.warning(f"Failed to set displayname: {e}") + self.name_set = False return True elif source.is_relaybot or self.displayname_source is None: self.displayname_source = source.tgid @@ -328,28 +348,29 @@ class Puppet(DBPuppet, BasePuppet): return False if not photo_id and not self.config["bridge.allow_avatar_remove"]: return False - if self.photo_id != photo_id: + if self.photo_id != photo_id or not self.avatar_set: if not photo_id: self.photo_id = "" - try: - await self.default_mxid_intent.set_avatar_url(ContentURI("")) - except MatrixError: - self.log.exception("Failed to set avatar") - self.photo_id = "" - return True - - loc = InputPeerPhotoFileLocation( - peer=await self.get_input_entity(source), photo_id=photo.photo_id, big=True - ) - file = await util.transfer_file_to_matrix(source.client, self.default_mxid_intent, loc) - if file: + self.avatar_url = None + elif self.photo_id != photo_id or not self.avatar_url: + file = await util.transfer_file_to_matrix( + client=source.client, + intent=self.default_mxid_intent, + location=InputPeerPhotoFileLocation( + peer=await self.get_input_entity(source), photo_id=photo.photo_id, big=True + ), + ) + if not file: + return False self.photo_id = photo_id - try: - await self.default_mxid_intent.set_avatar_url(file.mxc) - except MatrixError: - self.log.exception("Failed to set avatar") - self.photo_id = "" - return True + self.avatar_url = file.mxc + try: + await self.default_mxid_intent.set_avatar_url(self.avatar_url or "") + self.avatar_set = True + except Exception as e: + self.log.warning(f"Failed to set avatar: {e}") + self.avatar_set = False + return True return False async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool: diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index df6901ab..485a0534 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -448,7 +448,7 @@ class User(DBUser, AbstractUser, BaseUser): async def get_direct_chats(self) -> dict[UserID, list[RoomID]]: return { pu.Puppet.get_mxid_from_id(portal.tgid): [portal.mxid] - async for portal in po.Portal.find_private_chats(self.tgid) + async for portal in po.Portal.find_private_chats_of(self.tgid) if portal.mxid }