diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bd530ee..24bc4340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Added simple fallback message for live location and venue messages from Telegram. * Added support for `t.me/+code` style invite links in `!tg join`. +* Added support for showing channel profile when users send messages as a channel. * 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 39270496..e1d3f501 100644 --- a/mautrix_telegram/abstract_user.py +++ b/mautrix_telegram/abstract_user.py @@ -1,5 +1,5 @@ # mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan +# 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 @@ -15,7 +15,7 @@ # along with this program. If not, see . from __future__ import annotations -from typing import TYPE_CHECKING, Any, Type, Union +from typing import TYPE_CHECKING, Any, Union from abc import ABC, abstractmethod import asyncio import logging @@ -34,6 +34,7 @@ from telethon.tl.types import ( Chat, MessageActionChannelMigrateFrom, MessageEmpty, + PeerChannel, PeerChat, PeerUser, TypeUpdate, @@ -147,7 +148,7 @@ class AbstractUser(ABC): return self.client and self.client.is_connected() @property - def _proxy_settings(self) -> tuple[Type[Connection], tuple[Any, ...] | None]: + def _proxy_settings(self) -> tuple[type[Connection], tuple[Any, ...] | None]: proxy_type = self.config["telegram.proxy.type"].lower() connection = ConnectionTcpFull connection_data = ( @@ -385,7 +386,7 @@ class AbstractUser(ABC): if not message: return - puppet = await pu.Puppet.get_by_tgid(TelegramID(update.peer.user_id)) + puppet = await pu.Puppet.get_by_peer(update.peer) await puppet.intent.mark_read(portal.mxid, message.mxid) async def update_own_read_receipt( @@ -444,10 +445,7 @@ class AbstractUser(ABC): return if isinstance(update, (UpdateChannelUserTyping, UpdateChatUserTyping)): - # Can typing notifications come from non-user peers? - if not update.from_id.user_id: - return - sender = await pu.Puppet.get_by_tgid(TelegramID(update.from_id.user_id)) + sender = await pu.Puppet.get_by_peer(update.from_id) if not sender or not portal or not portal.mxid: return @@ -456,8 +454,8 @@ class AbstractUser(ABC): async def _handle_entity_updates(self, entities: dict[int, User | Chat | Channel]) -> None: try: - users = (entity for entity in entities.values() if isinstance(entity, User)) - puppets = ((await pu.Puppet.get_by_tgid(TelegramID(user.id)), user) for user in users) + users = (entity for entity in entities.values() if isinstance(entity, (User, Channel))) + puppets = ((await pu.Puppet.get_by_peer(user), user) for user in users) await asyncio.gather( *[puppet.try_update_info(self, info) async for puppet, info in puppets if puppet] ) @@ -515,8 +513,8 @@ class AbstractUser(ABC): portal = await po.Portal.get_by_entity(update.peer_id, tg_receiver=self.tgid) if update.out: sender = await pu.Puppet.get_by_tgid(self.tgid) - elif isinstance(update.from_id, PeerUser): - sender = await pu.Puppet.get_by_tgid(TelegramID(update.from_id.user_id)) + elif isinstance(update.from_id, (PeerUser, PeerChannel)): + sender = await pu.Puppet.get_by_peer(update.from_id) else: sender = None else: diff --git a/mautrix_telegram/db/puppet.py b/mautrix_telegram/db/puppet.py index c1a21e5f..3963faf9 100644 --- a/mautrix_telegram/db/puppet.py +++ b/mautrix_telegram/db/puppet.py @@ -1,5 +1,5 @@ # mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan +# 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 @@ -45,6 +45,7 @@ class Puppet: username: str | None photo_id: str | None is_bot: bool | None + is_channel: bool custom_mxid: UserID | None access_token: str | None @@ -61,7 +62,7 @@ class Puppet: columns: ClassVar[str] = ( "id, is_registered, displayname, displayname_source, displayname_contact, " - "displayname_quality, disable_updates, username, photo_id, is_bot, " + "displayname_quality, disable_updates, username, photo_id, is_bot, is_channel, " "custom_mxid, access_token, next_batch, base_url" ) @@ -103,6 +104,7 @@ class Puppet: self.username, self.photo_id, self.is_bot, + self.is_channel, self.custom_mxid, self.access_token, self.next_batch, @@ -114,7 +116,7 @@ class Puppet: "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," - " custom_mxid=$11, access_token=$12, next_batch=$13, base_url=$14 " + " is_channel=$11, custom_mxid=$12, access_token=$13, next_batch=$14, base_url=$15 " "WHERE id=$1" ) await self.db.execute(q, *self._values) @@ -123,8 +125,8 @@ class Puppet: q = ( "INSERT INTO puppet (" " id, is_registered, displayname, displayname_source, displayname_contact," - " displayname_quality, disable_updates, username, photo_id, is_bot," + " 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)" + ") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)" ) await self.db.execute(q, *self._values) diff --git a/mautrix_telegram/db/upgrade/__init__.py b/mautrix_telegram/db/upgrade/__init__.py index 33fc194e..cab1770b 100644 --- a/mautrix_telegram/db/upgrade/__init__.py +++ b/mautrix_telegram/db/upgrade/__init__.py @@ -2,4 +2,10 @@ from mautrix.util.async_db import UpgradeTable upgrade_table = UpgradeTable() -from . import v01_initial_revision, v02_sponsored_events, v03_reactions, v04_disappearing_messages +from . import ( + v01_initial_revision, + v02_sponsored_events, + v03_reactions, + v04_disappearing_messages, + v05_channel_ghosts, +) diff --git a/mautrix_telegram/db/upgrade/v05_channel_ghosts.py b/mautrix_telegram/db/upgrade/v05_channel_ghosts.py new file mode 100644 index 00000000..d46364b4 --- /dev/null +++ b/mautrix_telegram/db/upgrade/v05_channel_ghosts.py @@ -0,0 +1,25 @@ +# 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="Add separate ghost users for channel senders") +async def upgrade_v5(conn: Connection, scheme: str) -> None: + await conn.execute("ALTER TABLE puppet ADD COLUMN is_channel BOOLEAN NOT NULL DEFAULT false") + if scheme == "postgres": + await conn.execute("ALTER TABLE puppet ALTER COLUMN is_channel DROP DEFAULT") diff --git a/mautrix_telegram/formatter/from_telegram.py b/mautrix_telegram/formatter/from_telegram.py index 2517eb10..17272f7e 100644 --- a/mautrix_telegram/formatter/from_telegram.py +++ b/mautrix_telegram/formatter/from_telegram.py @@ -94,9 +94,7 @@ async def _add_forward_header( ) if not fwd_from_text: - puppet = await pu.Puppet.get_by_tgid( - TelegramID(fwd_from.from_id.user_id), create=False - ) + puppet = await pu.Puppet.get_by_peer(fwd_from.from_id, create=False) if puppet and puppet.displayname: fwd_from_text = puppet.displayname or puppet.mxid fwd_from_html = ( diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index 252926e1..4924063b 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -1,5 +1,5 @@ # mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan +# 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 @@ -66,11 +66,19 @@ class MatrixHandler(BaseMatrixHandler): ) -> None: intent = puppet.default_mxid_intent self.log.debug(f"{inviter.mxid} invited puppet for {puppet.tgid} to {room_id}") + if puppet.is_channel: + self.log.debug(f"Rejecting invite for {puppet.tgid} to {room_id}: puppet is a channel") + await intent.leave_room(room_id, reason="Channels can't be invited to chats") + return + if not await inviter.is_logged_in(): - await intent.error_and_leave( - room_id, text="Please log in before inviting Telegram puppets." + self.log.debug(f"Rejecting invite for {puppet.tgid} to {room_id}: user not logged in") + await intent.leave_room( + room_id, + reason="Only users who are logged into the bridge can invite Telegram ghosts.", ) return + portal = await po.Portal.get_by_mxid(room_id) if portal: if portal.peer_type == "user": diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 9c5d7da9..ea06a22b 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -1,5 +1,5 @@ # mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan +# 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 @@ -561,6 +561,8 @@ class Portal(DBPortal, BasePortal): await self.update_bridge_info() async def invite_telegram(self, source: u.User, puppet: p.Puppet | au.AbstractUser) -> None: + if puppet.is_channel: + raise ValueError("Can't invite channels to chats") if self.peer_type == "chat": await source.client( AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0) @@ -945,11 +947,12 @@ class Portal(DBPortal, BasePortal): if user_mxid == self.az.bot_mxid: continue - puppet_id = p.Puppet.get_id_from_mxid(user_mxid) - if puppet_id: - if puppet_id in allowed_tgids: + puppet = await p.Puppet.get_by_mxid(user_mxid) + if puppet: + # TODO figure out when/how to clean up channels from the member list + if puppet.id in allowed_tgids or puppet.is_channel: continue - if self.bot and puppet_id == self.bot.tgid: + if self.bot and puppet.id == self.bot.tgid: await self.bot.remove_chat(self.tgid) try: await self.main_intent.kick_user( @@ -2737,8 +2740,8 @@ class Portal(DBPortal, BasePortal): messages = client.iter_messages(entity, reverse=True, min_id=min_id) async for message in messages: sender = ( - await p.Puppet.get_by_tgid(TelegramID(message.from_id.user_id)) - if isinstance(message.from_id, PeerUser) + await p.Puppet.get_by_peer(message.from_id) + if isinstance(message.from_id, (PeerUser, PeerChannel)) else None ) # TODO handle service messages? @@ -2749,8 +2752,8 @@ class Portal(DBPortal, BasePortal): messages = await client.get_messages(entity, limit=limit) for message in reversed(messages): sender = ( - await p.Puppet.get_by_tgid(TelegramID(message.from_id.user_id)) - if isinstance(message.from_id, PeerUser) + await p.Puppet.get_by_peer(message.from_id) + if isinstance(message.from_id, (PeerUser, PeerChannel)) else None ) await self.handle_telegram_message(source, sender, message) @@ -2840,10 +2843,9 @@ class Portal(DBPortal, BasePortal): self, msg: DBMessage, reaction_list: list[MessagePeerReaction], total_count: int ) -> None: reactions = { - reaction.peer_id.user_id: reaction.reaction + p.Puppet.get_id_from_peer(reaction.peer_id): reaction.reaction for reaction in reaction_list - # TODO allow PeerChannel once channel senders are properly supported - if isinstance(reaction.peer_id, PeerUser) + if isinstance(reaction.peer_id, (PeerUser, PeerChannel)) } is_full = len(reactions) == total_count @@ -2954,10 +2956,10 @@ class Portal(DBPortal, BasePortal): if sender and not sender.displayname: self.log.debug( - f"Telegram user {sender.tgid} sent a message, but doesn't have a " - "displayname, updating info..." + f"Telegram user {sender.tgid} sent a message, but doesn't have a displayname," + " updating info..." ) - entity = await source.client.get_entity(PeerUser(sender.tgid)) + entity = await source.client.get_entity(sender.peer) await sender.update_info(source, entity) if not sender.displayname: self.log.debug( diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 535d1d67..3fb6006d 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -1,5 +1,5 @@ # mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan +# 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 @@ -20,10 +20,18 @@ from difflib import SequenceMatcher import unicodedata from telethon.tl.types import ( + Channel, + ChatPhoto, + ChatPhotoEmpty, InputPeerPhotoFileLocation, + PeerChannel, + PeerChat, PeerUser, + TypeChatPhoto, TypeInputPeer, TypeInputUser, + TypePeer, + TypeUserProfilePhoto, UpdateUserName, User, UserProfilePhoto, @@ -67,6 +75,7 @@ class Puppet(DBPuppet, BasePuppet): username: str | None = None, photo_id: str | None = None, is_bot: bool = False, + is_channel: bool = False, custom_mxid: UserID | None = None, access_token: str | None = None, next_batch: SyncToken | None = None, @@ -83,6 +92,7 @@ class Puppet(DBPuppet, BasePuppet): username=username, photo_id=photo_id, is_bot=is_bot, + is_channel=is_channel, custom_mxid=custom_mxid, access_token=access_token, next_batch=next_batch, @@ -109,7 +119,9 @@ class Puppet(DBPuppet, BasePuppet): @property def peer(self) -> PeerUser: - return PeerUser(user_id=self.tgid) + return ( + PeerChannel(channel_id=self.tgid) if self.is_channel else PeerUser(user_id=self.tgid) + ) @property def plain_displayname(self) -> str: @@ -185,9 +197,12 @@ class Puppet(DBPuppet, BasePuppet): return name @classmethod - def get_displayname(cls, info: User, enable_format: bool = True) -> tuple[str, int]: - fn = cls._filter_name(info.first_name) - ln = cls._filter_name(info.last_name) + def get_displayname(cls, info: User | Channel, enable_format: bool = True) -> tuple[str, int]: + if isinstance(info, Channel): + fn, ln = cls._filter_name(info.title), "" + else: + fn = cls._filter_name(info.first_name) + ln = cls._filter_name(info.last_name) data = { "phone number": info.phone if hasattr(info, "phone") else None, "username": info.username, @@ -214,14 +229,20 @@ class Puppet(DBPuppet, BasePuppet): return (cls.displayname_template.format_full(name) if enable_format else name), quality - async def try_update_info(self, source: au.AbstractUser, info: User) -> None: + async def try_update_info(self, source: au.AbstractUser, info: User | Channel) -> None: try: await self.update_info(source, info) except Exception: source.log.exception(f"Failed to update info of {self.tgid}") - async def update_info(self, source: au.AbstractUser, info: User) -> None: - changed = False + async def update_info(self, source: au.AbstractUser, info: User | Channel) -> None: + is_bot = False if isinstance(info, Channel) else info.bot + is_channel = isinstance(info, Channel) + changed = is_bot != self.is_bot or is_channel != self.is_channel + + self.is_bot = is_bot + self.is_channel = is_channel + if self.username != info.username: self.username = info.username changed = True @@ -233,32 +254,32 @@ class Puppet(DBPuppet, BasePuppet): except Exception: self.log.exception(f"Failed to update info from source {source.tgid}") - self.is_bot = info.bot - if changed: await self.save() async def update_displayname( - self, source: au.AbstractUser, info: User | UpdateUserName + self, source: au.AbstractUser, info: User | Channel | UpdateUserName ) -> bool: if self.disable_updates: return False if source.is_relaybot or source.is_bot: - allow_because = "user is bot" + allow_because = "source user is a bot" elif self.displayname_source == source.tgid: - allow_because = "user is the primary source" + allow_because = "source user is the primary source" + elif isinstance(info, Channel): + allow_because = "target user is a channel" elif not isinstance(info, UpdateUserName) and not info.contact: - allow_because = "user is not a contact" + allow_because = "target user is not a contact" elif not self.displayname_source: allow_because = "no primary source set" elif not self.displayname: - allow_because = "user has no name" + allow_because = "target user has no name" else: return False if isinstance(info, UpdateUserName): - info = await source.client.get_entity(PeerUser(self.tgid)) - if not info.contact: + info = await source.client.get_entity(self.peer) + if isinstance(info, Channel) or not info.contact: self.displayname_contact = False elif not self.displayname_contact: if not self.displayname: @@ -293,14 +314,14 @@ class Puppet(DBPuppet, BasePuppet): return False async def update_avatar( - self, source: au.AbstractUser, photo: UserProfilePhoto | UserProfilePhotoEmpty + self, source: au.AbstractUser, photo: TypeUserProfilePhoto | TypeChatPhoto ) -> bool: if self.disable_updates: return False - if photo is None or isinstance(photo, UserProfilePhotoEmpty): + if photo is None or isinstance(photo, (UserProfilePhotoEmpty, ChatPhotoEmpty)): photo_id = "" - elif isinstance(photo, UserProfilePhoto): + elif isinstance(photo, (UserProfilePhoto, ChatPhoto)): photo_id = str(photo.photo_id) else: self.log.warning(f"Unknown user profile photo type: {type(photo)}") @@ -345,7 +366,9 @@ class Puppet(DBPuppet, BasePuppet): @classmethod @async_getter_lock - async def get_by_tgid(cls, tgid: TelegramID, *, create: bool = True) -> Puppet | None: + async def get_by_tgid( + cls, tgid: TelegramID, *, create: bool = True, is_channel: bool = False + ) -> Puppet | None: if tgid is None: return None @@ -360,13 +383,37 @@ class Puppet(DBPuppet, BasePuppet): return puppet if create: - puppet = cls(tgid) + puppet = cls(tgid, is_channel=is_channel) await puppet.insert() puppet._add_to_cache() return puppet return None + @staticmethod + def get_id_from_peer(peer: TypePeer | User | Channel) -> TelegramID: + if isinstance(peer, PeerUser): + return TelegramID(peer.user_id) + elif isinstance(peer, PeerChannel): + return TelegramID(peer.channel_id) + elif isinstance(peer, PeerChat): + return TelegramID(peer.chat_id) + elif isinstance(peer, (User, Channel)): + return TelegramID(peer.id) + raise TypeError(f"invalid type {type(peer).__name__!r} in _id_from_peer()") + + @classmethod + async def get_by_peer( + cls, peer: TypePeer | User | Channel, *, create: bool = True + ) -> Puppet | None: + if isinstance(peer, PeerChat): + return None + return await cls.get_by_tgid( + cls.get_id_from_peer(peer), + create=create, + is_channel=isinstance(peer, (PeerChannel, Channel)), + ) + @classmethod def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Awaitable[Puppet | None]: return cls.get_by_tgid(cls.get_id_from_mxid(mxid), create=create) diff --git a/requirements.txt b/requirements.txt index 9800591f..42f04d2f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ python-magic>=0.4,<0.5 commonmark>=0.8,<0.10 aiohttp>=3,<4 yarl>=1,<2 -mautrix>=0.14.8,<0.15 +mautrix>=0.14.9,<0.15 #telethon>=1.24,<1.25 # Fork to make session storage async and update to layer 138 tulir-telethon==1.25.0a4