Add support for new reaction stuff

* Custom emojis in reactions
* Premium users can react 3 times to a single message
* Reactions to recent messages are now polled on read receipt
This commit is contained in:
Tulir Asokan
2022-09-17 14:25:04 +03:00
parent 95939dfa02
commit 026c39a3de
13 changed files with 316 additions and 116 deletions
+11
View File
@@ -152,6 +152,17 @@ class Message:
rows = await cls.db.fetch(q, mx_room, tg_space, *mxids) rows = await cls.db.fetch(q, mx_room, tg_space, *mxids)
return [cls._from_row(row) for row in rows] return [cls._from_row(row) for row in rows]
@classmethod
async def find_recent(
cls, mx_room: RoomID, not_sender: TelegramID, limit: int = 20
) -> list[Message]:
q = f"""
SELECT {cls.columns} FROM message
WHERE mx_room=$1 AND sender<>$2
ORDER BY tgid DESC LIMIT $3
"""
return [cls._from_row(row) for row in await cls.db.fetch(q, mx_room, not_sender, limit)]
@classmethod @classmethod
async def replace_temp_mxid(cls, temp_mxid: str, mx_room: RoomID, real_mxid: EventID) -> None: async def replace_temp_mxid(cls, temp_mxid: str, mx_room: RoomID, real_mxid: EventID) -> None:
q = "UPDATE message SET mxid=$1 WHERE mxid=$2 AND mx_room=$3" q = "UPDATE message SET mxid=$1 WHERE mxid=$2 AND mx_room=$3"
+8 -4
View File
@@ -50,6 +50,7 @@ class Puppet:
avatar_set: bool avatar_set: bool
is_bot: bool | None is_bot: bool | None
is_channel: bool is_channel: bool
is_premium: bool
custom_mxid: UserID | None custom_mxid: UserID | None
access_token: str | None access_token: str | None
@@ -67,7 +68,8 @@ class Puppet:
columns: ClassVar[str] = ( columns: ClassVar[str] = (
"id, is_registered, displayname, displayname_source, displayname_contact, " "id, is_registered, displayname, displayname_source, displayname_contact, "
"displayname_quality, disable_updates, username, phone, photo_id, avatar_url, " "displayname_quality, disable_updates, username, phone, photo_id, avatar_url, "
"name_set, avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url" "name_set, avatar_set, is_bot, is_channel, is_premium, "
"custom_mxid, access_token, next_batch, base_url"
) )
@classmethod @classmethod
@@ -108,6 +110,7 @@ class Puppet:
self.avatar_set, self.avatar_set,
self.is_bot, self.is_bot,
self.is_channel, self.is_channel,
self.is_premium,
self.custom_mxid, self.custom_mxid,
self.access_token, self.access_token,
self.next_batch, self.next_batch,
@@ -120,7 +123,7 @@ class Puppet:
SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5, SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,
displayname_quality=$6, disable_updates=$7, username=$8, phone=$9, photo_id=$10, displayname_quality=$6, disable_updates=$7, username=$8, phone=$9, photo_id=$10,
avatar_url=$11, name_set=$12, avatar_set=$13, is_bot=$14, is_channel=$15, avatar_url=$11, name_set=$12, avatar_set=$13, is_bot=$14, is_channel=$15,
custom_mxid=$16, access_token=$17, next_batch=$18, base_url=$19 is_premium=$16, custom_mxid=$17, access_token=$18, next_batch=$19, base_url=$20
WHERE id=$1 WHERE id=$1
""" """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
@@ -130,8 +133,9 @@ class Puppet:
INSERT INTO puppet ( INSERT INTO puppet (
id, is_registered, displayname, displayname_source, displayname_contact, id, is_registered, displayname, displayname_source, displayname_contact,
displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set, displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set,
avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url avatar_set, is_bot, is_channel, is_premium, 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, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
$19) $19, $20)
""" """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
+15 -6
View File
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record from asyncpg import Record
from attr import dataclass from attr import dataclass
from telethon.tl.types import ReactionCustomEmoji, ReactionEmoji, TypeReaction
from mautrix.types import EventID, RoomID from mautrix.types import EventID, RoomID
from mautrix.util.async_db import Database from mautrix.util.async_db import Database
@@ -58,9 +59,10 @@ class Reaction:
@classmethod @classmethod
async def get_by_sender( async def get_by_sender(
cls, mxid: EventID, mx_room: RoomID, tg_sender: TelegramID cls, mxid: EventID, mx_room: RoomID, tg_sender: TelegramID
) -> Reaction | None: ) -> list[Reaction]:
q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3" q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3"
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room, tg_sender)) rows = await cls.db.fetch(q, mxid, mx_room, tg_sender)
return [cls._from_row(row) for row in rows]
@classmethod @classmethod
async def get_all_by_message(cls, mxid: EventID, mx_room: RoomID) -> list[Reaction]: async def get_all_by_message(cls, mxid: EventID, mx_room: RoomID) -> list[Reaction]:
@@ -68,6 +70,13 @@ class Reaction:
rows = await cls.db.fetch(q, mxid, mx_room) rows = await cls.db.fetch(q, mxid, mx_room)
return [cls._from_row(row) for row in rows] return [cls._from_row(row) for row in rows]
@property
def telegram(self) -> TypeReaction:
if self.reaction.isdecimal():
return ReactionCustomEmoji(document_id=int(self.reaction))
else:
return ReactionEmoji(emoticon=self.reaction)
@property @property
def _values(self): def _values(self):
return ( return (
@@ -81,11 +90,11 @@ class Reaction:
async def save(self) -> None: async def save(self) -> None:
q = """ q = """
INSERT INTO reaction (mxid, mx_room, msg_mxid, tg_sender, reaction) INSERT INTO reaction (mxid, mx_room, msg_mxid, tg_sender, reaction)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (msg_mxid, mx_room, tg_sender) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (msg_mxid, mx_room, tg_sender, reaction)
DO UPDATE SET mxid=$1, reaction=$5 DO UPDATE SET mxid=excluded.mxid
""" """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
async def delete(self) -> None: async def delete(self) -> None:
q = "DELETE FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3" q = "DELETE FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3 AND reaction=$4"
await self.db.execute(q, self.msg_mxid, self.mx_room, self.tg_sender) await self.db.execute(q, self.msg_mxid, self.mx_room, self.tg_sender, self.reaction)
+5
View File
@@ -82,6 +82,11 @@ class TelegramFile:
file.thumbnail = await cls.get(thumbnail_id, _thumbnail=True) file.thumbnail = await cls.get(thumbnail_id, _thumbnail=True)
return file return file
@classmethod
async def find_by_mxc(cls, mxc: ContentURI) -> TelegramFile | None:
q = f"SELECT {cls.columns} FROM telegram_file WHERE mxc=$1"
return cls._from_row(await cls.db.fetchrow(q, mxc))
async def insert(self) -> None: async def insert(self) -> None:
q = ( q = (
"INSERT INTO telegram_file (id, mxc, mime_type, was_converted, size, width, height, " "INSERT INTO telegram_file (id, mxc, mime_type, was_converted, size, width, height, "
+1
View File
@@ -15,4 +15,5 @@ from . import (
v10_more_backfill_fields, v10_more_backfill_fields,
v11_backfill_queue, v11_backfill_queue,
v12_message_sender, v12_message_sender,
v13_multiple_reactions,
) )
@@ -26,6 +26,7 @@ async def create_latest_tables(conn: Connection) -> int:
tg_username TEXT, tg_username TEXT,
tg_phone TEXT, tg_phone TEXT,
is_bot BOOLEAN NOT NULL DEFAULT false, is_bot BOOLEAN NOT NULL DEFAULT false,
is_premium BOOLEAN NOT NULL DEFAULT false,
saved_contacts INTEGER NOT NULL DEFAULT 0 saved_contacts INTEGER NOT NULL DEFAULT 0
)""" )"""
) )
@@ -78,7 +79,7 @@ async def create_latest_tables(conn: Connection) -> int:
tg_sender BIGINT, tg_sender BIGINT,
reaction TEXT NOT NULL, reaction TEXT NOT NULL,
PRIMARY KEY (msg_mxid, mx_room, tg_sender), PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction),
UNIQUE (mxid, mx_room) UNIQUE (mxid, mx_room)
)""" )"""
) )
@@ -111,6 +112,7 @@ async def create_latest_tables(conn: Connection) -> int:
avatar_set BOOLEAN NOT NULL DEFAULT false, avatar_set BOOLEAN NOT NULL DEFAULT false,
is_bot BOOLEAN, is_bot BOOLEAN,
is_channel BOOLEAN NOT NULL DEFAULT false, is_channel BOOLEAN NOT NULL DEFAULT false,
is_premium BOOLEAN NOT NULL DEFAULT false,
access_token TEXT, access_token TEXT,
custom_mxid TEXT, custom_mxid TEXT,
@@ -135,6 +137,7 @@ async def create_latest_tables(conn: Connection) -> int:
ON UPDATE CASCADE ON DELETE SET NULL ON UPDATE CASCADE ON DELETE SET NULL
)""" )"""
) )
await conn.execute("CREATE INDEX telegram_file_mxc_idx ON telegram_file(mxc)")
await conn.execute( await conn.execute(
"""CREATE TABLE bot_chat ( """CREATE TABLE bot_chat (
id BIGINT PRIMARY KEY, id BIGINT PRIMARY KEY,
@@ -0,0 +1,54 @@
# 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 mautrix.util.async_db import Connection, Scheme
from . import upgrade_table
@upgrade_table.register(description="Allow multiple reactions from the same user")
async def upgrade_v13(conn: Connection, scheme: Scheme) -> None:
await conn.execute("CREATE INDEX telegram_file_mxc_idx ON telegram_file(mxc)")
await conn.execute('ALTER TABLE "user" ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false')
await conn.execute("ALTER TABLE puppet ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false")
if scheme == Scheme.POSTGRES:
await conn.execute(
"""
ALTER TABLE reaction
DROP CONSTRAINT reaction_pkey,
ADD CONSTRAINT reaction_pkey PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction)
"""
)
else:
await conn.execute(
"""CREATE TABLE new_reaction (
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
msg_mxid TEXT NOT NULL,
tg_sender BIGINT,
reaction TEXT NOT NULL,
PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction),
UNIQUE (mxid, mx_room)
)"""
)
await conn.execute(
"""
INSERT INTO new_reaction (mxid, mx_room, msg_mxid, tg_sender, reaction)
SELECT mxid, mx_room, msg_mxid, tg_sender, reaction FROM reaction
"""
)
await conn.execute("DROP TABLE reaction")
await conn.execute("ALTER TABLE new_reaction RENAME TO reaction")
+14 -9
View File
@@ -37,6 +37,7 @@ class User:
tg_username: str | None tg_username: str | None
tg_phone: str | None tg_phone: str | None
is_bot: bool is_bot: bool
is_premium: bool
saved_contacts: int saved_contacts: int
@classmethod @classmethod
@@ -45,7 +46,9 @@ class User:
return None return None
return cls(**row) return cls(**row)
columns: ClassVar[str] = "mxid, tgid, tg_username, tg_phone, is_bot, saved_contacts" columns: ClassVar[str] = ", ".join(
("mxid", "tgid", "tg_username", "tg_phone", "is_bot", "is_premium", "saved_contacts")
)
@classmethod @classmethod
async def get_by_tgid(cls, tgid: TelegramID) -> User | None: async def get_by_tgid(cls, tgid: TelegramID) -> User | None:
@@ -78,21 +81,23 @@ class User:
self.tg_username, self.tg_username,
self.tg_phone, self.tg_phone,
self.is_bot, self.is_bot,
self.is_premium,
self.saved_contacts, self.saved_contacts,
) )
async def save(self) -> None: async def save(self) -> None:
q = ( q = """
'UPDATE "user" SET tgid=$2, tg_username=$3, tg_phone=$4, is_bot=$5, saved_contacts=$6 ' UPDATE "user" SET tgid=$2, tg_username=$3, tg_phone=$4, is_bot=$5, is_premium=$6,
"WHERE mxid=$1" saved_contacts=$7
) WHERE mxid=$1
"""
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
async def insert(self) -> None: async def insert(self) -> None:
q = ( q = """
'INSERT INTO "user" (mxid, tgid, tg_username, tg_phone, is_bot, saved_contacts) ' INSERT INTO "user" (mxid, tgid, tg_username, tg_phone, is_bot, is_premium, saved_contacts)
"VALUES ($1, $2, $3, $4, $5, $6)" VALUES ($1, $2, $3, $4, $5, $6, $7)
) """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
async def get_contacts(self) -> list[TelegramID]: async def get_contacts(self) -> list[TelegramID]:
+190 -93
View File
@@ -16,6 +16,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, List, Union, cast from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, List, Union, cast
from collections import defaultdict
from datetime import datetime from datetime import datetime
from html import escape as escape_html from html import escape as escape_html
from sqlite3 import IntegrityError from sqlite3 import IntegrityError
@@ -52,6 +53,7 @@ from telethon.tl.functions.messages import (
EditChatTitleRequest, EditChatTitleRequest,
ExportChatInviteRequest, ExportChatInviteRequest,
GetMessageReactionsListRequest, GetMessageReactionsListRequest,
GetMessagesReactionsRequest,
MigrateChatRequest, MigrateChatRequest,
SendReactionRequest, SendReactionRequest,
SetTypingRequest, SetTypingRequest,
@@ -102,6 +104,8 @@ from telethon.tl.types import (
Photo, Photo,
PhotoEmpty, PhotoEmpty,
ReactionCount, ReactionCount,
ReactionCustomEmoji,
ReactionEmoji,
SendMessageCancelAction, SendMessageCancelAction,
SendMessageTypingAction, SendMessageTypingAction,
SponsoredMessage, SponsoredMessage,
@@ -113,11 +117,13 @@ from telethon.tl.types import (
TypeMessage, TypeMessage,
TypeMessageAction, TypeMessageAction,
TypePeer, TypePeer,
TypeReaction,
TypeUser, TypeUser,
TypeUserFull, TypeUserFull,
TypeUserProfilePhoto, TypeUserProfilePhoto,
UpdateChannelUserTyping, UpdateChannelUserTyping,
UpdateChatUserTyping, UpdateChatUserTyping,
UpdateMessageReactions,
UpdateNewMessage, UpdateNewMessage,
UpdateUserTyping, UpdateUserTyping,
User, User,
@@ -181,6 +187,7 @@ from .db import (
Message as DBMessage, Message as DBMessage,
Portal as DBPortal, Portal as DBPortal,
Reaction as DBReaction, Reaction as DBReaction,
TelegramFile as DBTelegramFile,
) )
from .tgclient import MautrixTelegramClient from .tgclient import MautrixTelegramClient
from .types import TelegramID from .types import TelegramID
@@ -204,6 +211,8 @@ UpdateTyping = Union[UpdateUserTyping, UpdateChatUserTyping, UpdateChannelUserTy
TypeChatPhoto = Union[ChatPhoto, ChatPhotoEmpty, Photo, PhotoEmpty] TypeChatPhoto = Union[ChatPhoto, ChatPhotoEmpty, Photo, PhotoEmpty]
MediaHandler = Callable[["au.AbstractUser", IntentAPI, Message, RelatesTo], Awaitable[EventID]] MediaHandler = Callable[["au.AbstractUser", IntentAPI, Message, RelatesTo], Awaitable[EventID]]
REACTION_POLL_MIN_INTERVAL = 20
class BridgingError(Exception): class BridgingError(Exception):
pass pass
@@ -261,6 +270,8 @@ class Portal(DBPortal, BasePortal):
_sponsored_seen: dict[UserID, bool] _sponsored_seen: dict[UserID, bool]
_new_messages_after_sponsored: bool _new_messages_after_sponsored: bool
_prev_reaction_poll: dict[UserID, float]
_msg_conv: putil.TelegramMessageConverter _msg_conv: putil.TelegramMessageConverter
def __init__( def __init__(
@@ -331,6 +342,8 @@ class Portal(DBPortal, BasePortal):
self._new_messages_after_sponsored = True self._new_messages_after_sponsored = True
self._bridging_blocked_at_runtime = False self._bridging_blocked_at_runtime = False
self._prev_reaction_poll = defaultdict(lambda: 0.0)
self._msg_conv = putil.TelegramMessageConverter(self) self._msg_conv = putil.TelegramMessageConverter(self)
# region Properties # region Properties
@@ -1440,8 +1453,13 @@ class Portal(DBPortal, BasePortal):
await user.client.send_read_acknowledge( await user.client.send_read_acknowledge(
self.peer, max_id=message.tgid, clear_mentions=True, clear_reactions=True self.peer, max_id=message.tgid, clear_mentions=True, clear_reactions=True
) )
if self.peer_type == "channel" and not self.megagroup: if self.peer_type == "channel":
asyncio.create_task(self._try_handle_read_for_sponsored_msg(user, event_id, timestamp)) if not self.megagroup:
asyncio.create_task(
self._try_handle_read_for_sponsored_msg(user, event_id, timestamp)
)
else:
asyncio.create_task(self._poll_telegram_reactions(user))
async def _preproc_kick_ban( async def _preproc_kick_ban(
self, user: u.User | p.Puppet, source: u.User self, user: u.User | p.Puppet, source: u.User
@@ -2212,12 +2230,31 @@ class Portal(DBPortal, BasePortal):
await real_deleter.client.delete_messages(self.peer, [message.tgid]) await real_deleter.client.delete_messages(self.peer, [message.tgid])
async def handle_matrix_reaction( async def handle_matrix_reaction(
self, user: u.User, target_event_id: EventID, reaction: str, reaction_event_id: EventID self, user: u.User, target_event_id: EventID, emoji: str, reaction_event_id: EventID
) -> None: ) -> None:
emoji_id = emoji
reaction = ReactionEmoji(emoticon=variation_selector.remove(emoji))
if emoji.startswith("mxc://"):
db_reaction = await DBTelegramFile.find_by_mxc(ContentURI(emoji))
if not db_reaction or not db_reaction.id.isdecimal():
self.log.debug(f"Dropping unknown reaction {emoji} by {user.mxid}")
if not self.has_bot:
await self.main_intent.redact(
self.mxid, reaction_event_id, reason="Unrecognized custom emoji"
)
await self._send_bridge_error(
user,
Exception("Unrecognized custom emoji"),
reaction_event_id,
EventType.REACTION,
)
return
reaction = ReactionCustomEmoji(document_id=int(db_reaction.id))
emoji_id = db_reaction.id
try: try:
async with self.reaction_lock(target_event_id): async with self.reaction_lock(target_event_id):
await self._handle_matrix_reaction( await self._handle_matrix_reaction(
user, target_event_id, reaction, reaction_event_id user, target_event_id, emoji_id, reaction, reaction_event_id
) )
except IgnoredMessageError as e: except IgnoredMessageError as e:
self.log.debug(str(e)) self.log.debug(str(e))
@@ -2244,7 +2281,12 @@ class Portal(DBPortal, BasePortal):
asyncio.create_task(self._send_message_status(reaction_event_id, err=None)) asyncio.create_task(self._send_message_status(reaction_event_id, err=None))
async def _handle_matrix_reaction( async def _handle_matrix_reaction(
self, user: u.User, target_event_id: EventID, emoji: str, reaction_event_id: EventID self,
user: u.User,
target_event_id: EventID,
emoji_id: str,
reaction: TypeReaction,
reaction_event_id: EventID,
) -> None: ) -> None:
tg_space = self.tgid if self.peer_type == "channel" else user.tgid tg_space = self.tgid if self.peer_type == "channel" else user.tgid
msg = await DBMessage.get_by_mxid(target_event_id, self.mxid, tg_space) msg = await DBMessage.get_by_mxid(target_event_id, self.mxid, tg_space)
@@ -2259,23 +2301,34 @@ class Portal(DBPortal, BasePortal):
elif msg.edit_index != 0: elif msg.edit_index != 0:
raise IgnoredMessageError(f"Ignoring Matrix reaction to edit event {target_event_id}") raise IgnoredMessageError(f"Ignoring Matrix reaction to edit event {target_event_id}")
emoji = variation_selector.remove(emoji) existing_reacts = await DBReaction.get_by_sender(msg.mxid, msg.mx_room, user.tgid)
existing_react = await DBReaction.get_by_sender(msg.mxid, msg.mx_room, user.tgid) new_tg_reactions: list[TypeReaction] = []
await user.client(SendReactionRequest(peer=self.peer, msg_id=msg.tgid, reaction=emoji)) reactions_to_remove: list[DBReaction] = []
if existing_react: max_reactions = 3 if user.is_premium else 1
puppet = await user.get_puppet() max_reactions -= 1 # Leave one reaction of space for the new reaction
await puppet.intent_for(self).redact(existing_react.mx_room, existing_react.mxid) for db_reaction in existing_reacts:
existing_react.mxid = reaction_event_id if db_reaction.reaction == emoji_id:
existing_react.reaction = emoji raise IgnoredMessageError("Ignoring duplicate Matrix reaction")
await existing_react.save() if len(new_tg_reactions) < max_reactions:
else: new_tg_reactions.append(db_reaction.telegram)
await DBReaction( else:
mxid=reaction_event_id, reactions_to_remove.append(db_reaction)
mx_room=self.mxid, new_tg_reactions.append(reaction)
msg_mxid=msg.mxid,
tg_sender=user.tgid, await user.client(
reaction=emoji, SendReactionRequest(peer=self.peer, msg_id=msg.tgid, reaction=new_tg_reactions)
).save() )
puppet = await user.get_puppet()
for db_reaction in reactions_to_remove:
await db_reaction.delete()
await puppet.intent_for(self).redact(db_reaction.mx_room, db_reaction.mxid)
await DBReaction(
mxid=reaction_event_id,
mx_room=self.mxid,
msg_mxid=msg.mxid,
tg_sender=user.tgid,
reaction=emoji_id,
).save()
async def _update_telegram_power_level( async def _update_telegram_power_level(
self, sender: u.User, user_id: TelegramID, level: int self, sender: u.User, user_id: TelegramID, level: int
@@ -2681,35 +2734,46 @@ class Portal(DBPortal, BasePortal):
return False return False
def _split_dm_reaction_counts(self, counts: list[ReactionCount]) -> list[MessagePeerReaction]: def _split_dm_reaction_counts(self, counts: list[ReactionCount]) -> list[MessagePeerReaction]:
if len(counts) == 1: reactions = []
item = counts[0] for item in counts:
if item.count == 2: if item.count == 2:
return [ reactions += [
MessagePeerReaction(reaction=item.reaction, peer_id=PeerUser(self.tgid)), MessagePeerReaction(reaction=item.reaction, peer_id=PeerUser(self.tgid)),
MessagePeerReaction( MessagePeerReaction(
reaction=item.reaction, peer_id=PeerUser(self.tg_receiver) reaction=item.reaction, peer_id=PeerUser(self.tg_receiver)
), ),
] ]
elif item.count == 1: elif item.count == 1:
return [ reactions.append(
MessagePeerReaction( MessagePeerReaction(
reaction=item.reaction, reaction=item.reaction,
peer_id=PeerUser(self.tg_receiver if item.chosen else self.tgid), peer_id=PeerUser(self.tg_receiver if item.chosen_order else self.tgid),
), )
] )
elif len(counts) == 2: return reactions
item1, item2 = counts
return [ async def _poll_telegram_reactions(self, source: au.AbstractUser) -> None:
MessagePeerReaction( now = time.monotonic()
reaction=item1.reaction, if self._prev_reaction_poll[source.mxid] + REACTION_POLL_MIN_INTERVAL > now:
peer_id=PeerUser(self.tg_receiver if item1.chosen else self.tgid), self.log.trace(
), f"Not polling reactions through {source.mxid}, "
MessagePeerReaction( f"last poll was less than {REACTION_POLL_MIN_INTERVAL} seconds ago"
reaction=item2.reaction, )
peer_id=PeerUser(self.tg_receiver if item2.chosen else self.tgid), return
), self._prev_reaction_poll[source.mxid] = now
] self.log.debug(f"Polling reactions for recent messages through {source.mxid}")
return [] messages = await DBMessage.find_recent(self.mxid, source.tgid)
message_ids = [message.tgid for message in messages]
updates = await source.client(GetMessagesReactionsRequest(peer=self.peer, id=message_ids))
for user in updates.users:
user: User
puppet = await p.Puppet.get_by_tgid(TelegramID(user.id))
await puppet.update_info(source, user)
for upd in updates.updates:
if isinstance(upd, UpdateMessageReactions):
await self.handle_telegram_reactions(source, TelegramID(upd.msg_id), upd.reactions)
else:
self.log.warning(f"Unexpected update type {type(upd)} in get reactions response")
async def try_handle_telegram_reactions( async def try_handle_telegram_reactions(
self, self,
@@ -2732,9 +2796,15 @@ class Portal(DBPortal, BasePortal):
dbm: DBMessage | None = None, dbm: DBMessage | None = None,
timestamp: datetime | None = None, timestamp: datetime | None = None,
) -> None: ) -> None:
if self.peer_type == "channel" and not self.megagroup: total_count = sum(item.count for item in data.results)
recent_reactions = data.recent_reactions or []
if total_count > 0 and not recent_reactions and not data.can_see_list:
# We don't know who reacted in a channel, so we can't bridge it properly either # We don't know who reacted in a channel, so we can't bridge it properly either
return return
if self.peer_type == "channel" and not self.megagroup:
# This should never happen with the previous if
self.log.warning(f"Can see reaction list in channel ({data!s})")
# return
tg_space = self.tgid if self.peer_type == "channel" else source.tgid tg_space = self.tgid if self.peer_type == "channel" else source.tgid
if dbm is None: if dbm is None:
@@ -2742,69 +2812,109 @@ class Portal(DBPortal, BasePortal):
if dbm is None: if dbm is None:
return return
total_count = sum(item.count for item in data.results) if not recent_reactions or len(recent_reactions) < total_count:
recent_reactions = data.recent_reactions or []
if not recent_reactions and total_count > 0:
if self.peer_type == "user": if self.peer_type == "user":
recent_reactions = self._split_dm_reaction_counts(data.results) recent_reactions = self._split_dm_reaction_counts(data.results)
elif source.is_bot: elif source.is_bot:
# Can't fetch exact reaction senders as a bot # Can't fetch exact reaction senders as a bot
return return
else: else:
# TODO this doesn't work for some reason # TODO should calls to this be limited?
return resp = await source.client(
# resp = await source.client( GetMessageReactionsListRequest(peer=self.peer, id=dbm.tgid, limit=100)
# GetMessageReactionsListRequest(peer=self.peer, id=dbm.tgid, limit=20) )
# ) recent_reactions = resp.reactions
# recent_reactions = resp.reactions
async with self.reaction_lock(dbm.mxid): async with self.reaction_lock(dbm.mxid):
await self._handle_telegram_reactions_locked( await self._handle_telegram_reactions_locked(
dbm, recent_reactions, total_count, timestamp=timestamp source, dbm, recent_reactions, total_count, timestamp=timestamp
) )
@staticmethod
def _reactions_filter(lst: list[TypeReaction], existing: DBReaction) -> bool:
if not lst:
return False
for reaction in lst:
if isinstance(reaction, ReactionCustomEmoji) and existing.reaction == str(
reaction.document_id
):
lst.remove(reaction)
return True
elif isinstance(reaction, ReactionEmoji) and existing.reaction == reaction.emoticon:
lst.remove(reaction)
return True
return False
@staticmethod
async def _get_reaction_limit(sender: TelegramID) -> int:
puppet = await p.Puppet.get_by_tgid(sender, create=False)
if puppet and puppet.is_premium:
return 3
return 1
async def _handle_telegram_reactions_locked( async def _handle_telegram_reactions_locked(
self, self,
source: au.AbstractUser,
msg: DBMessage, msg: DBMessage,
reaction_list: list[MessagePeerReaction], reaction_list: list[MessagePeerReaction],
total_count: int, total_count: int,
timestamp: datetime | None = None, timestamp: datetime | None = None,
) -> None: ) -> None:
reactions = { reactions: dict[TelegramID, list[TypeReaction]] = {}
p.Puppet.get_id_from_peer(reaction.peer_id): reaction.reaction custom_emoji_ids: list[int] = []
for reaction in reaction_list for reaction in reaction_list:
if isinstance(reaction.peer_id, (PeerUser, PeerChannel)) if isinstance(reaction.peer_id, (PeerUser, PeerChannel)) and isinstance(
} reaction.reaction, (ReactionEmoji, ReactionCustomEmoji)
is_full = len(reactions) == total_count ):
reactions.setdefault(p.Puppet.get_id_from_peer(reaction.peer_id), []).append(
reaction.reaction
)
if isinstance(reaction.reaction, ReactionCustomEmoji):
custom_emoji_ids.append(reaction.reaction.document_id)
is_full = len(reaction_list) == total_count
custom_emojis = await util.transfer_custom_emojis_to_matrix(source, custom_emoji_ids)
existing_reactions = await DBReaction.get_all_by_message(msg.mxid, msg.mx_room) existing_reactions = await DBReaction.get_all_by_message(msg.mxid, msg.mx_room)
removed: list[DBReaction] = [] removed: list[DBReaction] = []
changed: list[tuple[DBReaction, str]] = []
for existing_reaction in existing_reactions: for existing_reaction in existing_reactions:
new_reaction = reactions.get(existing_reaction.tg_sender) sender_id = existing_reaction.tg_sender
if new_reaction is None: new_reactions = reactions.get(sender_id)
if is_full: if self._reactions_filter(new_reactions, existing_reaction):
if new_reactions is not None and len(new_reactions) == 0:
reactions.pop(sender_id)
else:
if is_full or (
new_reactions is not None
and len(new_reactions) == await self._get_reaction_limit(sender_id)
):
removed.append(existing_reaction) removed.append(existing_reaction)
# else: assume the reaction is still there, too much effort to fetch it # else: assume the reaction is still there, too much effort to fetch it
elif new_reaction == existing_reaction.reaction:
reactions.pop(existing_reaction.tg_sender)
else:
changed.append((existing_reaction, new_reaction))
for sender, new_emoji in reactions.items(): new_reaction: TypeReaction
self.log.debug(f"Bridging reaction {new_emoji} by {sender} to {msg.tgid}") for sender, new_reactions in reactions.items():
puppet: p.Puppet = await p.Puppet.get_by_tgid(sender) for new_reaction in new_reactions:
mxid = await puppet.intent_for(self).react( if isinstance(new_reaction, ReactionEmoji):
msg.mx_room, msg.mxid, variation_selector.add(new_emoji), timestamp=timestamp emoji_id = new_reaction.emoticon
) matrix_reaction = variation_selector.add(new_reaction.emoticon)
await DBReaction( elif isinstance(new_reaction, ReactionCustomEmoji):
mxid=mxid, emoji_id = str(new_reaction.document_id)
mx_room=msg.mx_room, matrix_reaction = custom_emojis[new_reaction.document_id].mxc
msg_mxid=msg.mxid, else:
tg_sender=sender, self.log.warning("Unknown reaction type %s", type(new_reaction))
reaction=new_emoji, continue
).save() self.log.debug(f"Bridging reaction {emoji_id} by {sender} to {msg.tgid}")
puppet: p.Puppet = await p.Puppet.get_by_tgid(sender)
mxid = await puppet.intent_for(self).react(
msg.mx_room, msg.mxid, matrix_reaction, timestamp=timestamp
)
await DBReaction(
mxid=mxid,
mx_room=msg.mx_room,
msg_mxid=msg.mxid,
tg_sender=sender,
reaction=emoji_id,
).save()
for removed_reaction in removed: for removed_reaction in removed:
self.log.debug( self.log.debug(
f"Removing reaction {removed_reaction.reaction} by {removed_reaction.tg_sender} " f"Removing reaction {removed_reaction.reaction} by {removed_reaction.tg_sender} "
@@ -2813,19 +2923,6 @@ class Portal(DBPortal, BasePortal):
puppet = await p.Puppet.get_by_tgid(removed_reaction.tg_sender) puppet = await p.Puppet.get_by_tgid(removed_reaction.tg_sender)
await puppet.intent_for(self).redact(removed_reaction.mx_room, removed_reaction.mxid) await puppet.intent_for(self).redact(removed_reaction.mx_room, removed_reaction.mxid)
await removed_reaction.delete() await removed_reaction.delete()
for changed_reaction, new_emoji in changed:
self.log.debug(
f"Updating reaction {changed_reaction.reaction} -> {new_emoji} "
f"by {changed_reaction.tg_sender} to {msg.tgid}"
)
puppet = await p.Puppet.get_by_tgid(changed_reaction.tg_sender)
intent = puppet.intent_for(self)
await intent.redact(changed_reaction.mx_room, changed_reaction.mxid)
changed_reaction.mxid = await intent.react(
msg.mx_room, msg.mxid, variation_selector.add(new_emoji), timestamp=timestamp
)
changed_reaction.reaction = new_emoji
await changed_reaction.save()
async def handle_telegram_message( async def handle_telegram_message(
self, source: au.AbstractUser, sender: p.Puppet | None, evt: Message self, source: au.AbstractUser, sender: p.Puppet | None, evt: Message
+7 -1
View File
@@ -80,6 +80,7 @@ class Puppet(DBPuppet, BasePuppet):
avatar_set: bool = False, avatar_set: bool = False,
is_bot: bool = False, is_bot: bool = False,
is_channel: bool = False, is_channel: bool = False,
is_premium: bool = False,
custom_mxid: UserID | None = None, custom_mxid: UserID | None = None,
access_token: str | None = None, access_token: str | None = None,
next_batch: SyncToken | None = None, next_batch: SyncToken | None = None,
@@ -101,6 +102,7 @@ class Puppet(DBPuppet, BasePuppet):
avatar_set=avatar_set, avatar_set=avatar_set,
is_bot=is_bot, is_bot=is_bot,
is_channel=is_channel, is_channel=is_channel,
is_premium=is_premium,
custom_mxid=custom_mxid, custom_mxid=custom_mxid,
access_token=access_token, access_token=access_token,
next_batch=next_batch, next_batch=next_batch,
@@ -255,11 +257,15 @@ class Puppet(DBPuppet, BasePuppet):
async def update_info(self, source: au.AbstractUser, info: User | Channel) -> None: async def update_info(self, source: au.AbstractUser, info: User | Channel) -> None:
is_bot = False if isinstance(info, Channel) else info.bot is_bot = False if isinstance(info, Channel) else info.bot
is_premium = False if isinstance(info, Channel) else info.premium
is_channel = isinstance(info, Channel) is_channel = isinstance(info, Channel)
changed = is_bot != self.is_bot or is_channel != self.is_channel changed = (
is_bot != self.is_bot or is_channel != self.is_channel or is_premium != self.is_premium
)
self.is_bot = is_bot self.is_bot = is_bot
self.is_channel = is_channel self.is_channel = is_channel
self.is_premium = is_premium
if self.username != info.username: if self.username != info.username:
self.username = info.username self.username = info.username
+5
View File
@@ -92,6 +92,7 @@ class User(DBUser, AbstractUser, BaseUser):
tg_username: str | None = None, tg_username: str | None = None,
tg_phone: str | None = None, tg_phone: str | None = None,
is_bot: bool = False, is_bot: bool = False,
is_premium: bool = False,
saved_contacts: int = 0, saved_contacts: int = 0,
) -> None: ) -> None:
super().__init__( super().__init__(
@@ -100,6 +101,7 @@ class User(DBUser, AbstractUser, BaseUser):
tg_username=tg_username, tg_username=tg_username,
tg_phone=tg_phone, tg_phone=tg_phone,
is_bot=is_bot, is_bot=is_bot,
is_premium=is_premium,
saved_contacts=saved_contacts, saved_contacts=saved_contacts,
) )
AbstractUser.__init__(self) AbstractUser.__init__(self)
@@ -371,6 +373,9 @@ class User(DBUser, AbstractUser, BaseUser):
if self.is_bot != info.bot: if self.is_bot != info.bot:
self.is_bot = info.bot self.is_bot = info.bot
changed = True changed = True
if self.is_premium != info.premium:
self.is_premium = info.premium
changed = True
if self.tg_username != info.username: if self.tg_username != info.username:
self.tg_username = info.username self.tg_username = info.username
changed = True changed = True
+1 -1
View File
@@ -1,4 +1,4 @@
from .color_log import ColorFormatter from .color_log import ColorFormatter
from .file_transfer import convert_image, transfer_file_to_matrix from .file_transfer import convert_image, transfer_custom_emojis_to_matrix, transfer_file_to_matrix
from .parallel_file_transfer import parallel_transfer_to_telegram from .parallel_file_transfer import parallel_transfer_to_telegram
from .recursive_dict import recursive_del, recursive_get, recursive_set from .recursive_dict import recursive_del, recursive_get, recursive_set
+1 -1
View File
@@ -5,7 +5,7 @@ aiohttp>=3,<4
yarl>=1,<2 yarl>=1,<2
mautrix>=0.18.1,<0.19 mautrix>=0.18.1,<0.19
#telethon>=1.24,<1.25 #telethon>=1.24,<1.25
tulir-telethon==1.25.0a20 tulir-telethon==1.26.0a1
asyncpg>=0.20,<0.27 asyncpg>=0.20,<0.27
mako>=1,<2 mako>=1,<2
setuptools setuptools