Add proper support for receiving messages sent as a channel. Fixes #740

This commit is contained in:
Tulir Asokan
2022-02-01 15:20:05 +02:00
parent 2182dfc86b
commit 0f050edcd9
10 changed files with 149 additions and 62 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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