Skip to content
Snippets Groups Projects
Commit 6de84c61 authored by Tulir Asokan's avatar Tulir Asokan
Browse files

Initial rich profile implementation

Not tested
parent 0f7eebd6
No related branches found
No related tags found
No related merge requests found
......@@ -86,11 +86,11 @@ bridge:
displayname_template: "{displayname} (Telegram)"
# Set the preferred order of user identifiers which to use in the Matrix puppet display name.
# In the (hopefully unlikely) scenario that none of the given keys are found, the numeric user
# In the (hopefully impossible) scenario that none of the given keys are found, the numeric user
# ID is used.
#
# If the bridge is working properly, a phone number or an username should always be known, but
# the other one can very well be empty.
# Telegram requires displaynames, so a first name should always exist.
# Usernames might not exist.
#
# Valid keys:
# "full name" (First and/or last name)
......@@ -98,11 +98,17 @@ bridge:
# "first name"
# "last name"
# "username"
# "phone number"
displayname_preference:
- full name
- username
- phone number
# Rich profiles let you change the displayname template and preference per-room
# and also include telegram user metadata in the membership event that clients
# can use.
# Doing this requires sending membership events manually rather than using the
# simpler /profile/<user>/... endpoints, which might mean increased resource
# usage.
rich_profile: false
# Maximum number of members to sync per portal when starting up. Other members will be
# synced when they send messages. The maximum is 10000, after which the Telegram server
......
......@@ -201,7 +201,14 @@ class Config(DictWithRecursion):
copy("bridge.alias_template")
copy("bridge.displayname_template")
copy("bridge.displayname_preference")
allowed_prefs = ("full name", "full name reversed", "first name", "last name", "username")
dn_prefs = list({pref: None # Use dict to preserve order while removing duplicates
for pref in self.get("bridge.displayname_preference", None) or []
if pref in allowed_prefs})
if dn_prefs:
base["bridge.displayname_preference"] = dn_prefs
copy("bridge.rich_profile")
copy("bridge.max_initial_member_sync")
copy("bridge.sync_channel_members")
......
......@@ -18,7 +18,7 @@ from .base import Base
from .bot_chat import BotChat
from .message import Message
from .portal import Portal
from .puppet import Puppet
from .puppet import Puppet, PuppetPortal
from .room_state import RoomState
from .telegram_file import TelegramFile
from .user import User, UserPortal, Contact
......@@ -26,8 +26,8 @@ from .user_profile import UserProfile
def init(db_engine) -> None:
for table in (Portal, Message, User, Contact, UserPortal, Puppet, TelegramFile, UserProfile,
RoomState, BotChat):
for table in (Portal, Message, User, Contact, UserPortal, Puppet, PuppetPortal, TelegramFile,
UserProfile, RoomState, BotChat):
table.db = db_engine
table.t = table.__table__
table.c = table.t.c
......@@ -14,12 +14,12 @@
#
# 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 sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql import expression
from sqlalchemy.sql import expression, and_
from typing import Optional, Iterable
from ..types import MatrixUserID, MatrixRoomID, TelegramID
from ..types import MatrixUserID, TelegramID
from .base import Base
......@@ -32,6 +32,8 @@ class Puppet(Base):
displayname = Column(String, nullable=True)
displayname_source = Column(Integer, nullable=True) # type: Optional[TelegramID]
username = Column(String, nullable=True)
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
photo_id = Column(String, nullable=True)
is_bot = Column(Boolean, nullable=True)
matrix_registered = Column(Boolean, nullable=False, server_default=expression.false())
......@@ -39,12 +41,13 @@ class Puppet(Base):
@classmethod
def scan(cls, row) -> Optional['Puppet']:
(id, custom_mxid, access_token, displayname, displayname_source, username, photo_id,
is_bot, matrix_registered, disable_updates) = row
(id, custom_mxid, access_token, displayname, displayname_source, username, first_name,
last_name, photo_id, is_bot, matrix_registered, disable_updates) = row
return cls(id=id, custom_mxid=custom_mxid, access_token=access_token,
displayname=displayname, displayname_source=displayname_source,
username=username, photo_id=photo_id, is_bot=is_bot,
matrix_registered=matrix_registered, disable_updates=disable_updates)
username=username, first_name=first_name, last_name=last_name,
photo_id=photo_id, is_bot=is_bot, matrix_registered=matrix_registered,
disable_updates=disable_updates)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Puppet']:
......@@ -84,5 +87,46 @@ class Puppet(Base):
conn.execute(self.t.insert().values(
id=self.id, custom_mxid=self.custom_mxid, access_token=self.access_token,
displayname=self.displayname, displayname_source=self.displayname_source,
username=self.username, photo_id=self.photo_id, is_bot=self.is_bot,
username=self.username, first_name=self.first_name, last_name=self.last_name,
photo_id=self.photo_id, is_bot=self.is_bot,
matrix_registered=self.matrix_registered, disable_updates=self.disable_updates))
class PuppetPortal(Base):
__tablename__ = "puppet_portal"
puppet_id = Column(Integer, ForeignKey("puppet.id"), primary_key=True)
portal_id = Column(Integer, ForeignKey("portal.id"), primary_key=True) # type: TelegramID
displayname = Column(String(255), nullable=True)
@property
def _edit_identity(self):
return and_(self.c.puppet_id == self.puppet_id, self.c.portal_id == self.portal_id)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(puppet_id=self.puppet_id, portal_id=self.portal_id,
displayname=self.displayname))
@classmethod
def scan(cls, row) -> Optional['PuppetPortal']:
(puppet_id, portal_id, displayname) = row
return cls(puppet_id=puppet_id, portal_id=portal_id, displayname=displayname)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['PuppetPortal']:
try:
return cls.scan(next(rows))
except StopIteration:
return None
@classmethod
def all_for_puppet(cls, puppet_id: int) -> Iterable['PuppetPortal']:
rows = cls.db.execute(cls.t.select().where(cls.c.puppet_id == puppet_id))
for row in rows:
yield cls.scan(row)
@classmethod
def get(cls, puppet_id: int, portal_id: int) -> Optional['PuppetPortal']:
return cls._one_or_none(cls.db.execute(cls.t.select().where(
and_(cls.c.puppet_id == puppet_id, cls.c.portal_id == portal_id))))
......@@ -68,7 +68,8 @@ from mautrix_appservice import MatrixRequestError, IntentError, AppService, Inte
from .types import MatrixEventID, MatrixRoomID, MatrixUserID, TelegramID
from .context import Context
from .db import Portal as DBPortal, Message as DBMessage, TelegramFile as DBTelegramFile
from .db import (Portal as DBPortal, Message as DBMessage, TelegramFile as DBTelegramFile,
PuppetPortal as DBPuppetPortal)
from .util import ignore_coro, sane_mimetypes
from . import puppet as p, user as u, formatter, util
......@@ -304,6 +305,10 @@ class Portal:
# endregion
# region Matrix room info updating
async def get_puppet_displayname(self, info: User, data: Dict[str, str] = None) -> str:
return p.Puppet.get_displayname(info, self.get_config("displayname_preference"),
self.get_config("displayname_template"), data)
async def invite_to_matrix(self, users: InviteList) -> None:
if isinstance(users, str):
await self.main_intent.invite(self.mxid, users, check_cache=True)
......@@ -498,6 +503,7 @@ class Portal:
allowed_tgids.add(entity.id)
await puppet.intent.ensure_joined(self.mxid)
await puppet.update_info(source, entity)
await puppet.update_profile_in_room(self, entity)
user = u.User.get_by_tgid(TelegramID(entity.id))
if user:
......
......@@ -26,9 +26,9 @@ from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser,
InputPeerPhotoFileLocation, UserProfilePhotoEmpty)
from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError
from .types import MatrixUserID, TelegramID
from .db import Puppet as DBPuppet
from . import util
from .types import MatrixUserID, MatrixRoomID, TelegramID
from .db import Puppet as DBPuppet, PuppetPortal as DBPuppetPortal
from . import util, portal as p
if TYPE_CHECKING:
from .matrix import MatrixHandler
......@@ -56,10 +56,13 @@ class Puppet:
id: TelegramID,
access_token: Optional[str] = None,
custom_mxid: Optional[MatrixUserID] = None,
username: Optional[str] = None,
displayname: Optional[str] = None,
displayname_source: Optional[TelegramID] = None,
username: Optional[str] = None,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
photo_id: Optional[str] = None,
avatar_url: Optional[str] = None,
is_bot: bool = False,
is_registered: bool = False,
disable_updates: bool = False,
......@@ -69,10 +72,13 @@ class Puppet:
self.custom_mxid = custom_mxid # type: Optional[MatrixUserID]
self.default_mxid = self.get_mxid_from_id(self.id) # type: MatrixUserID
self.username = username # type: Optional[str]
self.displayname = displayname # type: Optional[str]
self.displayname_source = displayname_source # type: Optional[TelegramID]
self.username = username # type: Optional[str]
self.first_name = first_name # type: Optional[str]
self.last_name = last_name # type: Optional[str]
self.photo_id = photo_id # type: Optional[str]
self.avatar_url = avatar_url # type: Optional[str]
self.is_bot = is_bot # type: bool
self.is_registered = is_registered # type: bool
self.disable_updates = disable_updates # type: bool
......@@ -285,22 +291,26 @@ class Puppet:
def new_db_instance(self) -> DBPuppet:
return DBPuppet(id=self.id, access_token=self.access_token, custom_mxid=self.custom_mxid,
username=self.username, displayname=self.displayname,
displayname_source=self.displayname_source, photo_id=self.photo_id,
is_bot=self.is_bot, matrix_registered=self.is_registered,
disable_updates=self.disable_updates)
displayname=self.displayname, displayname_source=self.displayname_source,
username=self.username, first_name=self.first_name,
last_name=self.last_name, photo_id=self.photo_id,
avatar_url=self.avatar_url, is_bot=self.is_bot,
matrix_registered=self.is_registered, disable_updates=self.disable_updates)
@classmethod
def from_db(cls, db_puppet: DBPuppet) -> 'Puppet':
return Puppet(db_puppet.id, db_puppet.access_token, db_puppet.custom_mxid,
db_puppet.username, db_puppet.displayname, db_puppet.displayname_source,
db_puppet.photo_id, db_puppet.is_bot, db_puppet.matrix_registered,
db_puppet.displayname, db_puppet.displayname_source, db_puppet.username,
db_puppet.first_name, db_puppet.last_name, db_puppet.photo_id,
db_puppet.avatar_url, db_puppet.is_bot, db_puppet.matrix_registered,
db_puppet.disable_updates, db_instance=db_puppet)
def save(self) -> None:
self.db_instance.update(access_token=self.access_token, custom_mxid=self.custom_mxid,
username=self.username, displayname=self.displayname,
displayname_source=self.displayname_source, photo_id=self.photo_id,
displayname=self.displayname,
displayname_source=self.displayname_source, username=self.username,
first_name=self.first_name, last_name=self.last_name,
photo_id=self.photo_id, avatar_url=self.avatar_url,
is_bot=self.is_bot, matrix_registered=self.is_registered,
disable_updates=self.disable_updates)
......@@ -316,18 +326,21 @@ class Puppet:
return int(round(similarity * 100))
@staticmethod
def get_displayname(info: User, enable_format: bool = True) -> str:
data = {
"phone number": info.phone if hasattr(info, "phone") else None,
def _get_displayname_data(info: User) -> Dict[str, str]:
return {
"username": info.username,
"full name": " ".join([info.first_name or "", info.last_name or ""]).strip(),
"full name reversed": " ".join([info.first_name or "", info.last_name or ""]).strip(),
"first name": info.first_name,
"last name": info.last_name,
}
preferences = config["bridge.displayname_preference"]
@classmethod
def get_displayname(cls, info: User, preferences: List[str] = None, template: str = None,
data: Dict[str, str] = None) -> str:
data = data or cls._get_displayname_data(info)
name = None
for preference in preferences:
for preference in preferences or config["bridge.displayname_preference"]:
name = data[preference]
if name:
break
......@@ -337,24 +350,22 @@ class Puppet:
elif not name:
name = info.id
if not enable_format:
return name
return config["bridge.displayname_template"].format(
displayname=name)
return (template or config["bridge.displayname_template"]).format(displayname=name)
async def update_info(self, source: 'AbstractUser', info: User) -> None:
if self.disable_updates:
return
changed = False
if self.username != info.username:
self.username = info.username
changed = True
self.is_bot = info.bot
changed = await self.update_displayname(source, info) or changed
if isinstance(info.photo, UserProfilePhoto):
changed = await self.update_avatar(source, info.photo) or changed
self.is_bot = info.bot
if self.username != info.username:
self.username = info.username
changed = True
if changed:
self.save()
......@@ -372,22 +383,82 @@ class Puppet:
elif isinstance(info, UpdateUserName):
info = await source.client.get_entity(PeerUser(self.tgid))
displayname = self.get_displayname(info)
if displayname != self.displayname:
self.displayname = displayname
update = False
displayname = None
rich_profile = config["bridge.rich_profile"]
if not rich_profile:
displayname = self.get_displayname(info)
if self.displayname != displayname:
self.displayname = displayname
update = True
else:
if ((self.username != info.username or self.first_name != info.first_name
or self.last_name != info.last_name)):
self.username = info.username
self.first_name = info.first_name
self.last_name = info.last_name
update = True
if update:
self.displayname_source = source.tgid
try:
await self.default_mxid_intent.set_display_name(displayname)
await self.update_profile(displayname=displayname, user=info)
except MatrixRequestError:
self.log.exception("Failed to set displayname")
self.displayname = ""
self.displayname_source = None
return True
elif source.is_relaybot or self.displayname_source is None:
self.displayname_source = source.tgid
return True
return False
async def update_profile_in_room(self, portal: p.Portal, user: Optional[User] = None) -> None:
pupo = DBPuppetPortal.get(self.tgid, portal.tgid)
await self._set_rich_profile(pupo, portal, user)
async def _set_rich_profile(self, pupo: Optional[DBPuppetPortal], portal: p.Portal,
user: Optional[User] = None,
dn_data: Optional[Dict[str, str]] = None) -> None:
local_displayname = portal.get_puppet_displayname(user or self, data=dn_data)
if not pupo:
pupo = DBPuppetPortal(self.tgid, portal.tgid, local_displayname)
pupo.insert()
elif local_displayname != pupo.displayname:
pupo.displayname = local_displayname
pupo.update()
else:
return
await self.default_mxid_intent.send_state_event(portal.mxid, "m.room.member", {
"displayname": local_displayname,
"avatar_url": self.avatar_url,
"net.maunium.telegram.puppet": {
"user_id": self.tgid,
"username": self.username,
"first_name": self.first_name,
"last_name": self.last_name,
"is_bot": self.is_bot
}
})
async def update_profile(self, displayname: Optional[str] = None, user: Optional[User] = None,
avatar_url: Optional[str] = None) -> None:
if not config["bridge.rich_profile"]:
if displayname is not None:
await self.default_mxid_intent.set_display_name(displayname)
if avatar_url is not None:
await self.default_mxid_intent.set_avatar(avatar_url)
return
if avatar_url:
self.avatar_url = avatar_url
dn_data = self._get_displayname_data(user or self)
pupos = DBPuppetPortal.all_for_puppet(self.tgid)
for pupo in pupos:
portal = p.Portal.get_by_tgid(pupo.portal_id)
if not portal:
self.log.debug(f"Deleting PuppetPortal entry {pupo.puppet_id}-{pupo.portal_id}"
" for portal that doesn't exist")
pupo.delete()
continue
await self._set_rich_profile(pupo, portal, user, dn_data)
async def update_avatar(self, source: 'AbstractUser',
photo: Union[UserProfilePhoto, UserProfilePhotoEmpty]) -> bool:
if self.disable_updates:
......@@ -401,7 +472,7 @@ class Puppet:
if not photo_id:
self.photo_id = ""
try:
await self.default_mxid_intent.set_avatar("")
await self.update_profile(avatar_url="")
except MatrixRequestError:
self.log.exception("Failed to set avatar")
self.photo_id = ""
......@@ -417,7 +488,7 @@ class Puppet:
if file:
self.photo_id = photo_id
try:
await self.default_mxid_intent.set_avatar(file.mxc)
await self.update_profile(avatar_url=file.mxc)
except MatrixRequestError:
self.log.exception("Failed to set avatar")
self.photo_id = ""
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment