Improve profile info syncing

This commit is contained in:
Tulir Asokan
2022-02-01 20:51:54 +02:00
parent ecf3a12bd4
commit a55d9ae36a
9 changed files with 234 additions and 105 deletions

View File

@@ -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)

View File

@@ -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)}")

View File

@@ -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:

View File

@@ -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)

View File

@@ -8,4 +8,5 @@ from . import (
v03_reactions,
v04_disappearing_messages,
v05_channel_ghosts,
v06_puppet_avatar_url,
)

View File

@@ -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 <https://www.gnu.org/licenses/>.
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<>''")

View File

@@ -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

View File

@@ -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:

View File

@@ -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
}