diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py index 411ab686..ba071700 100644 --- a/mautrix_telegram/commands/handler.py +++ b/mautrix_telegram/commands/handler.py @@ -22,7 +22,7 @@ import commonmark from telethon.errors import FloodWaitError -from ..types import MatrixRoomID +from ..types import MatrixRoomID, MatrixEventID from ..util import format_duration from .. import user as u, context as c @@ -60,8 +60,9 @@ md_renderer = HtmlEscapingRenderer() class CommandEvent: - def __init__(self, processor: 'CommandProcessor', room: MatrixRoomID, sender: u.User, - command: str, args: List[str], is_management: bool, is_portal: bool) -> None: + def __init__(self, processor: 'CommandProcessor', room: MatrixRoomID, event: MatrixEventID, + sender: u.User, command: str, args: List[str], is_management: bool, + is_portal: bool) -> None: self.az = processor.az self.log = processor.log self.loop = processor.loop @@ -70,6 +71,7 @@ class CommandEvent: self.public_website = processor.public_website self.command_prefix = processor.command_prefix self.room_id = room + self.event_id = event self.sender = sender self.command = command self.args = args @@ -89,6 +91,9 @@ class CommandEvent: html = message return self.az.intent.send_notice(self.room_id, message, html=html) + def mark_read(self) -> Awaitable[Dict]: + return self.az.intent.mark_read(self.room_id, self.event_id) + class CommandHandler: def __init__(self, handler: Callable[[CommandEvent], Awaitable[Dict]], needs_auth: bool, @@ -175,9 +180,10 @@ class CommandProcessor: self.public_website = context.public_website self.command_prefix = self.config["bridge.command_prefix"] - async def handle(self, room: MatrixRoomID, sender: u.User, command: str, args: List[str], - is_management: bool, is_portal: bool) -> Optional[Dict]: - evt = CommandEvent(self, room, sender, command, args, is_management, is_portal) + async def handle(self, room: MatrixRoomID, event_id: MatrixEventID, sender: u.User, + command: str, args: List[str], is_management: bool, is_portal: bool + ) -> Optional[Dict]: + evt = CommandEvent(self, room, event_id, sender, command, args, is_management, is_portal) orig_command = command command = command.lower() try: diff --git a/mautrix_telegram/commands/portal/unbridge.py b/mautrix_telegram/commands/portal/unbridge.py index 95ff90ea..069c14ec 100644 --- a/mautrix_telegram/commands/portal/unbridge.py +++ b/mautrix_telegram/commands/portal/unbridge.py @@ -19,7 +19,7 @@ from typing import Dict, Callable, Optional from ...types import MatrixRoomID from ... import portal as po from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT -from .util import user_has_power_level, get_initial_state +from .util import user_has_power_level async def _get_portal_and_check_permission(evt: CommandEvent, permission: str, diff --git a/mautrix_telegram/commands/telegram/account.py b/mautrix_telegram/commands/telegram/account.py index 46ba4f3e..bfb71b37 100644 --- a/mautrix_telegram/commands/telegram/account.py +++ b/mautrix_telegram/commands/telegram/account.py @@ -22,7 +22,7 @@ from telethon.tl.types import Authorization from telethon.tl.functions.account import (UpdateUsernameRequest, GetAuthorizationsRequest, ResetAuthorizationRequest) -from mautrix_telegram.commands import command_handler, CommandEvent, SECTION_AUTH +from .. import command_handler, CommandEvent, SECTION_AUTH @command_handler(needs_auth=True, diff --git a/mautrix_telegram/commands/telegram/auth.py b/mautrix_telegram/commands/telegram/auth.py index 02dab059..fedb72d7 100644 --- a/mautrix_telegram/commands/telegram/auth.py +++ b/mautrix_telegram/commands/telegram/auth.py @@ -23,9 +23,9 @@ from telethon.errors import ( PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError, PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError) -from mautrix_telegram.commands import command_handler, CommandEvent, SECTION_AUTH -from mautrix_telegram import puppet as pu, user as u -from mautrix_telegram.util import format_duration, ignore_coro +from ... import puppet as pu, user as u +from ...commands import command_handler, CommandEvent, SECTION_AUTH +from ...util import format_duration, ignore_coro @command_handler(needs_auth=False, diff --git a/mautrix_telegram/commands/telegram/misc.py b/mautrix_telegram/commands/telegram/misc.py index 11be71ba..2d18ef06 100644 --- a/mautrix_telegram/commands/telegram/misc.py +++ b/mautrix_telegram/commands/telegram/misc.py @@ -19,18 +19,21 @@ import codecs import base64 import re -from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, +from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError, UserAlreadyParticipantError) -from telethon.tl.types import User as TLUser, TypeUpdates, MessageMediaGame +from telethon.tl.patched import Message +from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll, + TypePeer) from telethon.tl.types.messages import BotCallbackAnswer from telethon.tl.functions.messages import (ImportChatInviteRequest, CheckChatInviteRequest, - GetBotCallbackAnswerRequest) + GetBotCallbackAnswerRequest, SendVoteRequest) from telethon.tl.functions.channels import JoinChannelRequest -from mautrix_telegram import puppet as pu, portal as po -from mautrix_telegram.db import Message as DBMessage -from mautrix_telegram.types import TelegramID -from mautrix_telegram.commands import command_handler, CommandEvent, SECTION_MISC, SECTION_CREATING_PORTALS +from ... import puppet as pu, portal as po +from ...abstract_user import AbstractUser +from ...db import Message as DBMessage +from ...types import TelegramID +from ...commands import command_handler, CommandEvent, SECTION_MISC, SECTION_CREATING_PORTALS @command_handler(help_section=SECTION_MISC, @@ -167,6 +170,45 @@ async def sync(evt: CommandEvent) -> Optional[Dict]: PEER_TYPE_CHAT = b"g" +class MessageIDError(ValueError): + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str + ) -> Tuple[TypePeer, Message]: + try: + enc_id += (4 - len(enc_id) % 4) * "=" + enc_id = base64.b64decode(enc_id) + peer_type, enc_id = bytes([enc_id[0]]), enc_id[1:] + tgid = TelegramID(int(codecs.encode(enc_id[0:5], "hex_codec"), 16)) + msg_id = TelegramID(int(codecs.encode(enc_id[5:10], "hex_codec"), 16)) + space = None + if peer_type == PEER_TYPE_CHAT: + space = TelegramID(int(codecs.encode(enc_id[10:15], "hex_codec"), 16)) + except ValueError as e: + raise MessageIDError(f"Invalid {type_name} ID (format)") from e + + if peer_type == PEER_TYPE_CHAT: + orig_msg = DBMessage.get_by_tgid(msg_id, space) + if not orig_msg: + raise MessageIDError(f"Invalid {type_name} ID (original message not found in db)") + new_msg = DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, user.tgid) + if not new_msg: + raise MessageIDError(f"Invalid {type_name} ID (your copy of message not found in db)") + msg_id = new_msg.tgid + try: + peer = await user.client.get_input_entity(tgid) + except ValueError as e: + raise MessageIDError(f"Invalid {type_name} ID (chat not found)") from e + + msg = await user.client.get_messages(entity=peer, ids=msg_id) + if not msg: + raise MessageIDError(f"Invalid {type_name} ID (message not found)") + return peer, msg + + @command_handler(help_section=SECTION_MISC, help_args="<_play ID_>", help_text="Play a Telegram game.") @@ -179,38 +221,45 @@ async def play(evt: CommandEvent) -> Optional[Dict]: return await evt.reply("Bots can't play games :(") try: - play_id = evt.args[0] - play_id += (4 - len(play_id) % 4) * "=" - play_id = base64.b64decode(play_id) - peer_type, play_id = bytes([play_id[0]]), play_id[1:] - tgid = TelegramID(int(codecs.encode(play_id[0:5], "hex_codec"), 16)) - msg_id = TelegramID(int(codecs.encode(play_id[5:10], "hex_codec"), 16)) - space = None - if peer_type == PEER_TYPE_CHAT: - space = TelegramID(int(codecs.encode(play_id[10:15], "hex_codec"), 16)) - except ValueError: - return await evt.reply("Invalid play ID (format)") + peer, msg = await _parse_encoded_msgid(evt.sender, evt.args[0], type_name="play") + except MessageIDError as e: + return await evt.reply(e.message) - if peer_type == PEER_TYPE_CHAT: - orig_msg = DBMessage.get_by_tgid(msg_id, space) - if not orig_msg: - return await evt.reply("Invalid play ID (original message not found in db)") - new_msg = DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, evt.sender.tgid) - if not new_msg: - return await evt.reply("Invalid play ID (your copy of message not found in db)") - msg_id = new_msg.tgid - try: - peer = await evt.sender.client.get_input_entity(tgid) - except ValueError: - return await evt.reply("Invalid play ID (chat not found)") - - msg = await evt.sender.client.get_messages(entity=peer, ids=msg_id) - if not msg or not isinstance(msg.media, MessageMediaGame): + if not isinstance(msg.media, MessageMediaGame): return await evt.reply("Invalid play ID (message doesn't look like a game)") - game = await evt.sender.client(GetBotCallbackAnswerRequest(peer=peer, msg_id=msg_id, game=True)) + game = await evt.sender.client(GetBotCallbackAnswerRequest(peer=peer, msg_id=msg.id, game=True)) if not isinstance(game, BotCallbackAnswer): return await evt.reply("Game request response invalid") await evt.reply(f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n" f"{msg.media.game.description}") + + +@command_handler(help_section=SECTION_MISC, + help_args="<_poll ID_> <_choice ID_>", + help_text="Vote in a Telegram poll.") +async def vote(evt: CommandEvent) -> Optional[Dict]: + if len(evt.args) < 2: + return await evt.reply("**Usage:** `$cmdprefix+sp vote `") + elif not await evt.sender.is_logged_in(): + return await evt.reply("You must be logged in with a real account to vote in polls.") + elif evt.sender.is_bot: + return await evt.reply("Bots can't vote in polls :(") + + try: + peer, msg = await _parse_encoded_msgid(evt.sender, evt.args[0], type_name="poll") + except MessageIDError as e: + return await evt.reply(e.message) + + if not isinstance(msg.media, MessageMediaPoll): + return await evt.reply("Invalid poll ID (message doesn't look like a poll)") + + options = [base64.b64decode(option + (4 - len(option) % 4) * "=") + for option in evt.args[1:]] + try: + resp = await evt.sender.client(SendVoteRequest(peer=peer, msg_id=msg.id, options=options)) + except OptionsTooMuchError: + return await evt.reply("You passed too many options.") + # TODO use response + return await evt.mark_read() diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py index be9a845c..573cbd75 100644 --- a/mautrix_telegram/matrix.py +++ b/mautrix_telegram/matrix.py @@ -248,7 +248,7 @@ class MatrixHandler: # Not enough values to unpack, i.e. no arguments command = text args = [] - await self.commands.handle(room, sender, command, args, is_management, + await self.commands.handle(room, event_id, sender, command, args, is_management, is_portal=portal is not None) @staticmethod diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 97e6dafc..0a0336bd 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -48,7 +48,7 @@ from telethon.tl.patched import Message, MessageService from telethon.tl.types import ( Channel, ChatAdminRights, ChatBannedRights, ChannelFull, ChannelParticipantAdmin, ChannelParticipantCreator, ChannelParticipantsRecent, ChannelParticipantsSearch, Chat, ChatFull, - ChatInviteEmpty, ChatParticipantAdmin, ChatParticipantCreator, ChatPhoto, Poll, + ChatInviteEmpty, ChatParticipantAdmin, ChatParticipantCreator, ChatPhoto, Poll, PollAnswer, DocumentAttributeFilename, DocumentAttributeImageSize, DocumentAttributeSticker, DocumentAttributeVideo, FileLocation, GeoPoint, InputChannel, InputChatUploadedPhoto, InputPeerChannel, InputPeerChat, InputPeerUser, InputUser, InputUserSelf, @@ -1502,14 +1502,23 @@ class Portal: "net.maunium.telegram.unsupported": True, }, timestamp=evt.date, external_url=self.get_external_url(evt)) - async def handle_telegram_poll(self, _: 'AbstractUser', intent: IntentAPI, evt: Message, + async def handle_telegram_poll(self, source: 'AbstractUser', intent: IntentAPI, evt: Message, relates_to: dict) -> dict: poll = evt.media.poll # type: Poll + poll_id = self._encode_msgid(source, evt) text = (f"Poll: {poll.question}\n\n" - + "\n".join(f"* {answer.text}" for answer in poll.answers)) - html = (f"Poll: {poll.question}
\n
    " - + "\n".join(f"
  1. {answer.text}
  2. " for answer in poll.answers) - + "
") + + "\n".join(f"* {answer.text}" for answer in poll.answers) + + "\n" + + f"Poll ID: {poll_id}") + + def enc(answer: PollAnswer) -> str: + return base64.b64encode(answer.option).decode("utf-8").rstrip("=") + + html = (f"Poll: {poll.question}
\n\n" + + f"Poll ID: {poll_id}") await intent.set_typing(self.mxid, is_typing=False) return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to, msgtype="m.text", timestamp=evt.date, @@ -1520,25 +1529,28 @@ class Portal: hex_value = "{0:010x}".format(i) return codecs.decode(hex_value, "hex_codec") - async def handle_telegram_game(self, source: 'AbstractUser', intent: IntentAPI, - evt: Message, _: dict = None): - game = evt.media.game + def _encode_msgid(self, source: 'AbstractUser', evt: Message) -> str: if self.peer_type == "channel": - play_id = base64.b64encode(b"c" - + self._int_to_bytes(self.tgid) - + self._int_to_bytes(evt.id)) + play_id = (b"c" + + self._int_to_bytes(self.tgid) + + self._int_to_bytes(evt.id)) elif self.peer_type == "chat": - play_id = base64.b64encode(b"g" - + self._int_to_bytes(self.tgid) - + self._int_to_bytes(evt.id) - + self._int_to_bytes(source.tgid)) + play_id = (b"g" + + self._int_to_bytes(self.tgid) + + self._int_to_bytes(evt.id) + + self._int_to_bytes(source.tgid)) elif self.peer_type == "user": - play_id = base64.b64encode(b"u" - + self._int_to_bytes(self.tgid) - + self._int_to_bytes(evt.id)) + play_id = (b"u" + + self._int_to_bytes(self.tgid) + + self._int_to_bytes(evt.id)) else: raise ValueError("Portal has invalid peer type") - play_id = play_id.decode("utf-8").rstrip("=") + return base64.b64encode(play_id).decode("utf-8").rstrip("=") + + async def handle_telegram_game(self, source: 'AbstractUser', intent: IntentAPI, + evt: Message, relates_to: dict = None): + game = evt.media.game + play_id = self._encode_msgid(source, evt) command = f"!tg play {play_id}" override_text = f"Run {command} in your bridge management room to play {game.title}" override_entities = [MessageEntityPre(offset=len("Run "), length=len(command), language="")]