diff --git a/example-config.yaml b/example-config.yaml index a91ee949..d2807b30 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -107,12 +107,12 @@ bridge: # If no channel admins have logged into the bridge, the bridge won't be able to sync the member # list regardless of this setting. sync_channel_members: true - # Whether or not to automatically sync the Matrix room state (mostly unpuppeted displaynames) - # at startup and when creating a bridge. - sync_matrix_state: true # The maximum number of simultaneous Telegram deletions to handle. # A large number of simultaneous redactions could put strain on your homeserver. max_telegram_delete: 10 + # Whether or not to automatically sync the Matrix room state (mostly unpuppeted displaynames) + # at startup and when creating a bridge. + sync_matrix_state: true # Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix # login website (see appservice.public config section) allow_matrix_login: true @@ -120,6 +120,9 @@ bridge: # Only enable this if your displayname_template has some static part that the bridge can use to # reliably identify what is a plaintext highlight. plaintext_highlights: false + # Show message editing as a reply to the original message. + # If this is false, message edits are not shown at all, as Matrix does not support editing yet. + edits_as_replies: true # Highlight changed/added parts in edits. Requires lxml. highlight_edits: false # Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix. @@ -132,6 +135,20 @@ bridge: sync_with_custom_puppets: true # Set to false to disable link previews in messages sent to Telegram. telegram_link_preview: true + # Use inline images instead of a separate message for the caption. + # N.B. Inline images are not supported on all clients (e.g. Riot iOS). + inline_images: false + + # Whether to bridge Telegram bot messages as m.notices or m.texts. + bot_messages_as_notices: true + bridge_notices: + # Whether or not Matrix bot messages (type m.notice) should be bridged. + default: false + # List of user IDs for whom the previous flag is flipped. + # e.g. if bridge_notices.default is false, notices from other users will not be bridged, but + # notices from users listed here will be bridged. + exceptions: + - "@importantbot:example.com" # Some config options related to Telegram message deduplication. # The default values are usually fine, but some debug messages/warnings might recommend you @@ -143,26 +160,6 @@ bridge: # You might need to increase this on high-traffic bridge instances. cache_queue_length: 20 - # Show message editing as a reply to the original message. - # If this is false, message edits are not shown at all, as Matrix does not support editing yet. - edits_as_replies: false - bridge_notices: - # Whether or not Matrix bot messages (type m.notice) should be bridged. - default: false - # List of user IDs for whom the previous flag is flipped. - # e.g. if bridge_notices.default is false, notices from other users will not be bridged, but - # notices from users listed here will be bridged. - exceptions: - - "@importantbot:example.com" - # Whether to bridge Telegram bot messages as m.notices or m.texts. - bot_messages_as_notices: true - # Use inline images instead of a separate message for the caption. - # N.B. Inline images are not supported on all clients (e.g. Riot iOS). - inline_images: false - # Whether to send stickers as the new native m.sticker type or normal m.images. - # Old versions of Riot don't support the new type at all. - # Remember that proper sticker support always requires Pillow to convert webp into png. - native_stickers: true # The formats to use when sending messages to Telegram via the relay bot. # diff --git a/mautrix_telegram/commands/__init__.py b/mautrix_telegram/commands/__init__.py index 7e376fa4..cb11c5f0 100644 --- a/mautrix_telegram/commands/__init__.py +++ b/mautrix_telegram/commands/__init__.py @@ -2,4 +2,4 @@ CommandHandler, CommandProcessor, CommandEvent, SECTION_GENERAL, SECTION_AUTH, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT, SECTION_MISC, SECTION_ADMIN) -from . import clean_rooms, auth, meta, telegram, portal +from . import portal, telegram, clean_rooms, matrix_auth, meta diff --git a/mautrix_telegram/commands/matrix_auth.py b/mautrix_telegram/commands/matrix_auth.py new file mode 100644 index 00000000..d1ccad17 --- /dev/null +++ b/mautrix_telegram/commands/matrix_auth.py @@ -0,0 +1,101 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 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 typing import Dict, Optional + +from . import command_handler, CommandEvent, SECTION_AUTH +from .. import puppet as pu + + +@command_handler(needs_auth=True, needs_matrix_puppeting=True, + help_section=SECTION_AUTH, + help_text="Revert your Telegram account's Matrix puppet to use the default Matrix " + "account.") +async def logout_matrix(evt: CommandEvent) -> Optional[Dict]: + puppet = pu.Puppet.get(evt.sender.tgid) + if not puppet.is_real_user: + return await evt.reply("You are not logged in with your Matrix account.") + await puppet.switch_mxid(None, None) + return await evt.reply("Reverted your Telegram account's Matrix puppet back to the default.") + + +@command_handler(needs_auth=True, management_only=True, needs_matrix_puppeting=True, + help_section=SECTION_AUTH, + help_text="Replace your Telegram account's Matrix puppet with your own Matrix " + "account") +async def login_matrix(evt: CommandEvent) -> Optional[Dict]: + puppet = pu.Puppet.get(evt.sender.tgid) + if puppet.is_real_user: + return await evt.reply("You have already logged in with your Matrix account. " + "Log out with `$cmdprefix+sp logout-matrix` first.") + allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True) + if allow_matrix_login: + evt.sender.command_status = { + "next": enter_matrix_token, + "action": "Matrix login", + } + if evt.config["appservice.public.enabled"]: + prefix = evt.config["appservice.public.external"] + token = evt.public_website.make_token(evt.sender.mxid, "/matrix-login") + url = f"{prefix}/matrix-login?token={token}" + if allow_matrix_login: + return await evt.reply( + "This bridge instance allows you to log in inside or outside Matrix.\n\n" + "If you would like to log in within Matrix, please send your Matrix access token " + "here.\n" + f"If you would like to log in outside of Matrix, [click here]({url}).\n\n" + "Logging in outside of Matrix is recommended, because in-Matrix login would save " + "your access token in the message history.") + return await evt.reply("This bridge instance does not allow logging in inside Matrix.\n\n" + f"Please visit [the login page]({url}) to log in.") + elif allow_matrix_login: + return await evt.reply( + "This bridge instance does not allow you to log in outside of Matrix.\n\n" + "Please send your Matrix access token here to log in.") + return await evt.reply("This bridge instance has been configured to not allow logging in.") + + +@command_handler(needs_auth=True, needs_matrix_puppeting=True, + help_section=SECTION_AUTH, + help_text="Pings the server with the stored matrix authentication") +async def ping_matrix(evt: CommandEvent) -> Optional[Dict]: + puppet = pu.Puppet.get(evt.sender.tgid) + if not puppet.is_real_user: + return await evt.reply("You are not logged in with your Matrix account.") + resp = await puppet.init_custom_mxid() + if resp == pu.PuppetError.InvalidAccessToken: + return await evt.reply("Your access token is invalid.") + elif resp == pu.PuppetError.Success: + return await evt.reply("Your Matrix login is working.") + return await evt.reply(f"Unknown response while checking your Matrix login: {resp}.") + + +async def enter_matrix_token(evt: CommandEvent) -> Dict: + evt.sender.command_status = None + + puppet = pu.Puppet.get(evt.sender.tgid) + if puppet.is_real_user: + return await evt.reply("You have already logged in with your Matrix account. " + "Log out with `$cmdprefix+sp logout-matrix` first.") + + resp = await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) + if resp == pu.PuppetError.OnlyLoginSelf: + return await evt.reply("You can only log in as your own Matrix user.") + elif resp == pu.PuppetError.InvalidAccessToken: + return await evt.reply("Failed to verify access token.") + assert resp == pu.PuppetError.Success, "Encountered an unhandled PuppetError." + return await evt.reply( + f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}.") diff --git a/mautrix_telegram/commands/portal.py b/mautrix_telegram/commands/portal.py deleted file mode 100644 index f5cb0433..00000000 --- a/mautrix_telegram/commands/portal.py +++ /dev/null @@ -1,646 +0,0 @@ -# -*- coding: future_fstrings -*- -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2018 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 typing import Dict, Callable, Optional, Tuple, Coroutine, Awaitable -from io import StringIO -import asyncio - -from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError, - UsernameNotModifiedError, UsernameOccupiedError) -from telethon.tl.types import ChatForbidden, ChannelForbidden -from mautrix_appservice import MatrixRequestError, IntentAPI - -from ..types import MatrixRoomID, TelegramID -from ..config import yaml -from ..util import ignore_coro -from .. import portal as po, user as u, util -from . import (command_handler, CommandEvent, - SECTION_ADMIN, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT, SECTION_MISC) - - -@command_handler(needs_admin=True, needs_auth=False, name="set-pl", - help_section=SECTION_ADMIN, - help_args="<_level_> [_mxid_]", - help_text="Set a temporary power level without affecting Telegram.") -async def set_power_level(evt: CommandEvent) -> Dict: - try: - level = int(evt.args[0]) - except KeyError: - return await evt.reply("**Usage:** `$cmdprefix+sp set-power [mxid]`") - except ValueError: - return await evt.reply("The level must be an integer.") - levels = await evt.az.intent.get_power_levels(evt.room_id) - mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid - levels["users"][mxid] = level - try: - await evt.az.intent.set_power_levels(evt.room_id, levels) - except MatrixRequestError: - evt.log.exception("Failed to set power level.") - return await evt.reply("Failed to set power level.") - return {} - - -@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False, - help_section=SECTION_MISC, - help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.") -async def sync_state(evt: CommandEvent) -> Dict: - portal = po.Portal.get_by_mxid(evt.room_id) - if not portal: - return await evt.reply("This is not a portal room.") - elif not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"): - return await evt.reply(f"You do not have the permissions to synchronize this room.") - - await portal.sync_matrix_members() - await evt.reply("Synchronization complete") - - -@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False, - help_section=SECTION_MISC, - help_text="Get the ID of the Telegram chat where this room is bridged.") -async def id(evt: CommandEvent) -> Dict: - portal = po.Portal.get_by_mxid(evt.room_id) - if not portal: - return await evt.reply("This is not a portal room.") - tgid = portal.tgid - if portal.peer_type == "chat": - tgid = -tgid - elif portal.peer_type == "channel": - tgid = f"-100{tgid}" - await evt.reply(f"This room is bridged to Telegram chat ID `{tgid}`.") - - -@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, - help_text="Get a Telegram invite link to the current chat.") -async def invite_link(evt: CommandEvent) -> Dict: - portal = po.Portal.get_by_mxid(evt.room_id) - if not portal: - return await evt.reply("This is not a portal room.") - - if portal.peer_type == "user": - return await evt.reply("You can't invite users to private chats.") - - try: - link = await portal.get_invite_link(evt.sender) - return await evt.reply(f"Invite link to {portal.title}: {link}") - except ValueError as e: - return await evt.reply(e.args[0]) - except ChatAdminRequiredError: - return await evt.reply("You don't have the permission to create an invite link.") - - -async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50 - ) -> bool: - if sender.is_admin: - return True - # Make sure the state store contains the power levels. - try: - await intent.get_power_levels(room) - except MatrixRequestError: - return False - return intent.state_store.has_power_level(room, sender.mxid, - event=f"net.maunium.telegram.{event}", - default=default) - - -async def _get_portal_and_check_permission(evt: CommandEvent, permission: str, - action: Optional[str] = None - ) -> Optional[po.Portal]: - room_id = MatrixRoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id - - portal = po.Portal.get_by_mxid(room_id) - if not portal: - that_this = "This" if room_id == evt.room_id else "That" - await evt.reply(f"{that_this} is not a portal room.") - return None - - if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, permission): - action = action or f"{permission.replace('_', ' ')}s" - await evt.reply(f"You do not have the permissions to {action} that portal.") - return None - return portal - - -def _get_portal_murder_function(action: str, room_id: str, function: Callable, command: str, - completed_message: str) -> Dict: - async def post_confirm(confirm) -> Optional[Dict]: - confirm.sender.command_status = None - if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}": - await function() - if confirm.room_id != room_id: - return await confirm.reply(completed_message) - else: - return await confirm.reply(f"{action} cancelled.") - return None - - return { - "next": post_confirm, - "action": action, - } - - -@command_handler(needs_auth=False, needs_puppeting=False, - help_section=SECTION_PORTAL_MANAGEMENT, - help_text="Remove all users from the current portal room and forget the portal. " - "Only works for group chats; to delete a private chat portal, simply " - "leave the room.") -async def delete_portal(evt: CommandEvent) -> Optional[Dict]: - portal = await _get_portal_and_check_permission(evt, "unbridge") - if not portal: - return None - - evt.sender.command_status = _get_portal_murder_function("Portal deletion", portal.mxid, - portal.cleanup_and_delete, "delete", - "Portal successfully deleted.") - return await evt.reply("Please confirm deletion of portal " - f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) " - f"to Telegram chat \"{portal.title}\" " - "by typing `$cmdprefix+sp confirm-delete`" - "\n\n" - "**WARNING:** If the bridge bot has the power level to do so, **this " - "will kick ALL users** in the room. If you just want to remove the " - "bridge, use `$cmdprefix+sp unbridge` instead.") - - -@command_handler(needs_auth=False, needs_puppeting=False, - help_section=SECTION_PORTAL_MANAGEMENT, - help_text="Remove puppets from the current portal room and forget the portal.") -async def unbridge(evt: CommandEvent) -> Optional[Dict]: - portal = await _get_portal_and_check_permission(evt, "unbridge") - if not portal: - return None - - evt.sender.command_status = _get_portal_murder_function("Room unbridging", portal.mxid, - portal.unbridge, "unbridge", - "Room successfully unbridged.") - return await evt.reply(f"Please confirm unbridging chat \"{portal.title}\" from room " - f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) " - "by typing `$cmdprefix+sp confirm-unbridge`") - - -@command_handler(needs_auth=False, needs_puppeting=False, - help_section=SECTION_PORTAL_MANAGEMENT, - help_args="[_id_]", - help_text="Bridge the current Matrix room to the Telegram chat with the given " - "ID. The ID must be the prefixed version that you get with the `/id` " - "command of the Telegram-side bot.") -async def bridge(evt: CommandEvent) -> Dict: - if len(evt.args) == 0: - return await evt.reply("**Usage:** " - "`$cmdprefix+sp bridge [Matrix room ID]`") - room_id = MatrixRoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id - that_this = "This" if room_id == evt.room_id else "That" - - portal = po.Portal.get_by_mxid(room_id) - if portal: - return await evt.reply(f"{that_this} room is already a portal room.") - - if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"): - return await evt.reply(f"You do not have the permissions to bridge {that_this} room.") - - # The /id bot command provides the prefixed ID, so we assume - tgid_str = evt.args[0] - if tgid_str.startswith("-100"): - tgid = TelegramID(int(tgid_str[4:])) - peer_type = "channel" - elif tgid_str.startswith("-"): - tgid = TelegramID(-int(tgid_str)) - peer_type = "chat" - else: - return await evt.reply("That doesn't seem like a prefixed Telegram chat ID.\n\n" - "If you did not get the ID using the `/id` bot command, please " - "prefix channel IDs with `-100` and normal group IDs with `-`.\n\n" - "Bridging private chats to existing rooms is not allowed.") - - portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type) - if not portal.allow_bridging(): - return await evt.reply("This bridge doesn't allow bridging that Telegram chat.\n" - "If you're the bridge admin, try " - "`$cmdprefix+sp filter whitelist ` first.") - if portal.mxid: - has_portal_message = ( - "That Telegram chat already has a portal at " - f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). ") - if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"): - return await evt.reply(f"{has_portal_message}" - "Additionally, you do not have the permissions to unbridge " - "that room.") - evt.sender.command_status = { - "next": confirm_bridge, - "action": "Room bridging", - "mxid": portal.mxid, - "bridge_to_mxid": room_id, - "tgid": portal.tgid, - "peer_type": portal.peer_type, - } - return await evt.reply(f"{has_portal_message}" - "However, you have the permissions to unbridge that room.\n\n" - "To delete that portal completely and continue bridging, use " - "`$cmdprefix+sp delete-and-continue`. To unbridge the portal " - "without kicking Matrix users, use `$cmdprefix+sp unbridge-and-" - "continue`. To cancel, use `$cmdprefix+sp cancel`") - evt.sender.command_status = { - "next": confirm_bridge, - "action": "Room bridging", - "bridge_to_mxid": room_id, - "tgid": portal.tgid, - "peer_type": portal.peer_type, - } - return await evt.reply("That Telegram chat has no existing portal. To confirm bridging the " - "chat to this room, use `$cmdprefix+sp continue`") - - -async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal" - ) -> Tuple[bool, Optional[Coroutine[None, None, None]]]: - if not portal.mxid: - await evt.reply("The portal seems to have lost its Matrix room between you" - "calling `$cmdprefix+sp bridge` and this command.\n\n" - "Continuing without touching previous Matrix room...") - return True, None - elif evt.args[0] == "delete-and-continue": - return True, portal.cleanup_room(portal.main_intent, portal.mxid, - message="Portal deleted (moving to another room)") - elif evt.args[0] == "unbridge-and-continue": - return True, portal.cleanup_room(portal.main_intent, portal.mxid, - message="Room unbridged (portal moving to another room)", - puppets_only=True) - else: - await evt.reply( - "The chat you were trying to bridge already has a Matrix portal room.\n\n" - "Please use `$cmdprefix+sp delete-and-continue` or `$cmdprefix+sp unbridge-and-" - "continue` to either delete or unbridge the existing room (respectively) and " - "continue with the bridging.\n\n" - "If you changed your mind, use `$cmdprefix+sp cancel` to cancel.") - return False, None - - -async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]: - status = evt.sender.command_status - try: - portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"]) - bridge_to_mxid = status["bridge_to_mxid"] - except KeyError: - evt.sender.command_status = None - return await evt.reply("Fatal error: tgid or peer_type missing from command_status. " - "This shouldn't happen unless you're messing with the command " - "handler code.") - if "mxid" in status: - ok, coro = await cleanup_old_portal_while_bridging(evt, portal) - if not ok: - return None - elif coro: - ignore_coro(asyncio.ensure_future(coro, loop=evt.loop)) - await evt.reply("Cleaning up previous portal room...") - elif portal.mxid: - evt.sender.command_status = None - return await evt.reply("The portal seems to have created a Matrix room between you " - "calling `$cmdprefix+sp bridge` and this command.\n\n" - "Please start over by calling the bridge command again.") - elif evt.args[0] != "continue": - return await evt.reply("Please use `$cmdprefix+sp continue` to confirm the bridging or " - "`$cmdprefix+sp cancel` to cancel.") - - evt.sender.command_status = None - is_logged_in = await evt.sender.is_logged_in() - user = evt.sender if is_logged_in else evt.tgbot - try: - entity = await user.client.get_entity(portal.peer) - except Exception: - evt.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer) - if is_logged_in: - return await evt.reply("Failed to get info of telegram chat. " - "You are logged in, are you in that chat?") - else: - return await evt.reply("Failed to get info of telegram chat. " - "You're not logged in, is the relay bot in the chat?") - if isinstance(entity, (ChatForbidden, ChannelForbidden)): - if is_logged_in: - return await evt.reply("You don't seem to be in that chat.") - else: - return await evt.reply("The bot doesn't seem to be in that chat.") - - direct = False - - portal.mxid = bridge_to_mxid - portal.title, portal.about, levels = await get_initial_state(evt.az.intent, evt.room_id) - portal.photo_id = "" - portal.save() - - ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, - levels=levels), - loop=evt.loop)) - - return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.") - - -async def get_initial_state(intent: IntentAPI, room_id: str) -> Tuple[str, str, Dict]: - state = await intent.get_room_state(room_id) - title = None - about = None - levels = None - for event in state: - try: - if event["type"] == "m.room.name": - title = event["content"]["name"] - elif event["type"] == "m.room.topic": - about = event["content"]["topic"] - elif event["type"] == "m.room.power_levels": - levels = event["content"] - elif event["type"] == "m.room.canonical_alias": - title = title or event["content"]["alias"] - except KeyError: - # Some state event probably has empty content - pass - return title, about, levels - - -@command_handler(help_section=SECTION_CREATING_PORTALS, - help_args="[_type_]", - help_text="Create a Telegram chat of the given type for the current Matrix room. " - "The type is either `group`, `supergroup` or `channel` (defaults to " - "`group`).") -async def create(evt: CommandEvent) -> Dict: - type = evt.args[0] if len(evt.args) > 0 else "group" - if type not in {"chat", "group", "supergroup", "channel"}: - return await evt.reply( - "**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`") - - if po.Portal.get_by_mxid(evt.room_id): - return await evt.reply("This is already a portal room.") - - if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"): - return await evt.reply("You do not have the permissions to bridge this room.") - - title, about, levels = await get_initial_state(evt.az.intent, evt.room_id) - if not title: - return await evt.reply("Please set a title before creating a Telegram chat.") - - supergroup = type == "supergroup" - type = { - "supergroup": "channel", - "channel": "channel", - "chat": "chat", - "group": "chat", - }[type] - - portal = po.Portal(tgid=None, mxid=evt.room_id, title=title, about=about, peer_type=type) - try: - await portal.create_telegram_chat(evt.sender, supergroup=supergroup) - except ValueError as e: - portal.delete() - return await evt.reply(e.args[0]) - return await evt.reply(f"Telegram chat created. ID: {portal.tgid}") - - -@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, - help_text="Upgrade a normal Telegram group to a supergroup.") -async def upgrade(evt: CommandEvent) -> Dict: - portal = po.Portal.get_by_mxid(evt.room_id) - if not portal: - return await evt.reply("This is not a portal room.") - elif portal.peer_type == "channel": - return await evt.reply("This is already a supergroup or a channel.") - elif portal.peer_type == "user": - return await evt.reply("You can't upgrade private chats.") - - try: - await portal.upgrade_telegram_chat(evt.sender) - return await evt.reply(f"Group upgraded to supergroup. New ID: -100{portal.tgid}") - except ChatAdminRequiredError: - return await evt.reply("You don't have the permission to upgrade this group.") - except ValueError as e: - return await evt.reply(e.args[0]) - - -@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, - help_text="View or change per-portal settings.", - help_args="<`help`|_subcommand_> [...]") -async def config(evt: CommandEvent) -> None: - cmd = evt.args[0].lower() if len(evt.args) > 0 else "help" - if cmd not in ("view", "defaults", "set", "unset", "add", "del"): - await config_help(evt) - return - elif cmd == "defaults": - await config_defaults(evt) - return - - portal = po.Portal.get_by_mxid(evt.room_id) - if not portal: - await evt.reply("This is not a portal room.") - return - elif cmd == "view": - await config_view(evt, portal) - return - - key = evt.args[1] if len(evt.args) > 1 else None - value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 else None - if cmd == "set": - await config_set(evt, portal, key, value) - elif cmd == "unset": - await config_unset(evt, portal, key) - elif cmd == "add" or cmd == "del": - await config_add_del(evt, portal, key, value, cmd) - else: - return - portal.save() - - -def config_help(evt: CommandEvent) -> Awaitable[Dict]: - return evt.reply("""**Usage:** `$cmdprefix config [...]`. Subcommands: - -* **help** - View this help text. -* **view** - View the current config data. -* **defaults** - View the default config values. -* **set** <_key_> <_value_> - Set a config value. -* **unset** <_key_> - Remove a config value. -* **add** <_key_> <_value_> - Add a value to an array. -* **del** <_key_> <_value_> - Remove a value from an array. -""") - - -def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[Dict]: - stream = StringIO() - yaml.dump(portal.local_config, stream) - return evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}```") - - -def config_defaults(evt: CommandEvent) -> Awaitable[Dict]: - stream = StringIO() - yaml.dump({ - "edits_as_replies": evt.config["bridge.edits_as_replies"], - "bridge_notices": { - "default": evt.config["bridge.bridge_notices.default"], - "exceptions": evt.config["bridge.bridge_notices.exceptions"], - }, - "bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"], - "inline_images": evt.config["bridge.inline_images"], - "native_stickers": evt.config["bridge.native_stickers"], - "message_formats": evt.config["bridge.message_formats"], - "state_event_formats": evt.config["bridge.state_event_formats"], - "telegram_link_preview": evt.config["bridge.telegram_link_preview"], - }, stream) - return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```") - - -def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[Dict]: - if not key or value is None: - return evt.reply(f"**Usage:** `$cmdprefix+sp config set `") - elif util.recursive_set(portal.local_config, key, value): - return evt.reply(f"Successfully set the value of `{key}` to `{value}`.") - else: - return evt.reply(f"Failed to set value of `{key}`. " - "Does the path contain non-map types?") - - -def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Dict]: - if not key: - return evt.reply(f"**Usage:** `$cmdprefix+sp config unset `") - elif util.recursive_del(portal.local_config, key): - return evt.reply(f"Successfully deleted `{key}` from config.") - else: - return evt.reply(f"`{key}` not found in config.") - - -def config_add_del(evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str - ) -> Awaitable[Dict]: - if not key or value is None: - return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") - - arr = util.recursive_get(portal.local_config, key) - if not arr: - return evt.reply(f"`{key}` not found in config. " - f"Maybe do `$cmdprefix+sp config set {key} []` first?") - elif not isinstance(arr, list): - return evt.reply("`{key}` does not seem to be an array.") - elif cmd == "add": - if value in arr: - return evt.reply(f"The array at `{key}` already contains `{value}`.") - arr.append(value) - return evt.reply(f"Successfully added `{value}` to the array at `{key}`") - else: - if value not in arr: - return evt.reply(f"The array at `{key}` does not contain `{value}`.") - arr.remove(value) - return evt.reply(f"Successfully removed `{value}` from the array at `{key}`") - - -@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, - help_args="<_name_|`-`>", - help_text="Change the username of a supergroup/channel. " - "To disable, use a dash (`-`) as the name.") -async def group_name(evt: CommandEvent) -> Dict: - if len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp group-name `") - - portal = po.Portal.get_by_mxid(evt.room_id) - if not portal: - return await evt.reply("This is not a portal room.") - elif portal.peer_type != "channel": - return await evt.reply("Only channels and supergroups have usernames.") - - try: - await portal.set_telegram_username(evt.sender, - evt.args[0] if evt.args[0] != "-" else "") - if portal.username: - return await evt.reply(f"Username of channel changed to {portal.username}.") - else: - return await evt.reply(f"Channel is now private.") - except ChatAdminRequiredError: - return await evt.reply( - "You don't have the permission to set the username of this channel.") - except UsernameNotModifiedError: - if portal.username: - return await evt.reply("That is already the username of this channel.") - else: - return await evt.reply("This channel is already private") - except UsernameOccupiedError: - return await evt.reply("That username is already in use.") - except UsernameInvalidError: - return await evt.reply("Invalid username") - - -@command_handler(needs_admin=True, - help_section=SECTION_ADMIN, - help_args="<`whitelist`|`blacklist`>", - help_text="Change whether the bridge will allow or disallow bridging rooms by " - "default.") -async def filter_mode(evt: CommandEvent) -> Dict: - try: - mode = evt.args[0] - if mode not in ("whitelist", "blacklist"): - raise ValueError() - except (IndexError, ValueError): - return await evt.reply("**Usage:** `$cmdprefix+sp filter-mode `") - - evt.config["bridge.filter.mode"] = mode - evt.config.save() - po.Portal.filter_mode = mode - if mode == "whitelist": - return await evt.reply("The bridge will now disallow bridging chats by default.\n" - "To allow bridging a specific chat, use" - "`!filter whitelist `.") - else: - return await evt.reply("The bridge will now allow bridging chats by default.\n" - "To disallow bridging a specific chat, use" - "`!filter blacklist `.") - - -@command_handler(needs_admin=True, - help_section=SECTION_ADMIN, - help_args="<`whitelist`|`blacklist`> <_chat ID_>", - help_text="Allow or disallow bridging a specific chat.") -async def filter(evt: CommandEvent) -> Optional[Dict]: - try: - action = evt.args[0] - if action not in ("whitelist", "blacklist", "add", "remove"): - raise ValueError() - - id_str = evt.args[1] - if id_str.startswith("-100"): - id = int(id_str[4:]) - elif id_str.startswith("-"): - id = int(id_str[1:]) - else: - id = int(id_str) - except (IndexError, ValueError): - return await evt.reply("**Usage:** `$cmdprefix+sp filter `") - - mode = evt.config["bridge.filter.mode"] - if mode not in ("blacklist", "whitelist"): - return await evt.reply(f"Unknown filter mode \"{mode}\". Please fix the bridge config.") - - list = evt.config["bridge.filter.list"] - - if action in ("blacklist", "whitelist"): - action = "add" if mode == action else "remove" - - def save() -> None: - evt.config["bridge.filter.list"] = list - evt.config.save() - po.Portal.filter_list = list - - if action == "add": - if id in list: - return await evt.reply(f"That chat is already {mode}ed.") - list.append(id) - save() - return await evt.reply(f"Chat ID added to {mode}.") - elif action == "remove": - if id not in list: - return await evt.reply(f"That chat is not {mode}ed.") - list.remove(id) - save() - return await evt.reply(f"Chat ID removed from {mode}.") - return None diff --git a/mautrix_telegram/commands/portal/__init__.py b/mautrix_telegram/commands/portal/__init__.py new file mode 100644 index 00000000..4856d98a --- /dev/null +++ b/mautrix_telegram/commands/portal/__init__.py @@ -0,0 +1 @@ +from . import admin, bridge, config, create_chat, filter, misc, unbridge diff --git a/mautrix_telegram/commands/portal/admin.py b/mautrix_telegram/commands/portal/admin.py new file mode 100644 index 00000000..2a2e22c0 --- /dev/null +++ b/mautrix_telegram/commands/portal/admin.py @@ -0,0 +1,101 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 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 typing import Dict +import asyncio + +from mautrix_appservice import MatrixRequestError + +from ... import portal as po, puppet as pu, user as u +from .. import command_handler, CommandEvent, SECTION_ADMIN + + +@command_handler(needs_admin=True, needs_auth=False, name="set-pl", + help_section=SECTION_ADMIN, + help_args="<_level_> [_mxid_]", + help_text="Set a temporary power level without affecting Telegram.") +async def set_power_level(evt: CommandEvent) -> Dict: + try: + level = int(evt.args[0]) + except KeyError: + return await evt.reply("**Usage:** `$cmdprefix+sp set-pl [mxid]`") + except ValueError: + return await evt.reply("The level must be an integer.") + levels = await evt.az.intent.get_power_levels(evt.room_id) + mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid + levels["users"][mxid] = level + try: + await evt.az.intent.set_power_levels(evt.room_id, levels) + except MatrixRequestError: + evt.log.exception("Failed to set power level.") + return await evt.reply("Failed to set power level.") + return {} + + +@command_handler(needs_admin=True, needs_auth=False, + help_section=SECTION_ADMIN, + help_args="", + help_text="Clear internal bridge caches") +async def clear_db_cache(evt: CommandEvent) -> Dict: + try: + section = evt.args[0].lower() + except KeyError: + return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache
`") + if section == "portal": + po.Portal.by_tgid = {} + po.Portal.by_mxid = {} + await evt.reply("Cleared portal cache") + elif section == "puppet": + pu.Puppet.cache = {} + for puppet in pu.Puppet.by_custom_mxid.values(): + puppet.sync_task.cancel() + pu.Puppet.by_custom_mxid = {} + await asyncio.gather( + *[puppet.init_custom_mxid() for puppet in pu.Puppet.all_with_custom_mxid()], + loop=evt.loop) + await evt.reply("Cleared puppet cache and restarted custom puppet syncers") + elif section == "user": + u.User.by_mxid = { + user.mxid: user + for user in u.User.by_tgid.values() + } + await evt.reply("Cleared non-logged-in user cache") + else: + return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache
`") + + +@command_handler(needs_admin=True, needs_auth=False, + help_section=SECTION_ADMIN, + help_args="[user]", + help_text="Reload and reconnect a user") +async def reload_user(evt: CommandEvent) -> Dict: + if len(evt.args) > 0: + mxid = evt.args[0] + else: + mxid = evt.sender.mxid + user = u.User.get_by_mxid(mxid, create=False) + if not user: + return await evt.reply("User not found") + puppet = pu.Puppet.get_by_custom_mxid(mxid) + if puppet: + puppet.sync_task.cancel() + await user.stop() + user.delete(delete_db=False) + user = u.User.get_by_mxid(mxid) + await user.ensure_started() + if puppet: + await puppet.init_custom_mxid() + await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})") diff --git a/mautrix_telegram/commands/portal/bridge.py b/mautrix_telegram/commands/portal/bridge.py new file mode 100644 index 00000000..cb6c9b7f --- /dev/null +++ b/mautrix_telegram/commands/portal/bridge.py @@ -0,0 +1,181 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 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 typing import Dict, Optional, Tuple, Coroutine +import asyncio + +from telethon.tl.types import ChatForbidden, ChannelForbidden + +from ...types import MatrixRoomID, TelegramID +from ...util import ignore_coro +from ... import portal as po +from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS +from .util import user_has_power_level, get_initial_state + + +@command_handler(needs_auth=False, needs_puppeting=False, + help_section=SECTION_CREATING_PORTALS, + help_args="[_id_]", + help_text="Bridge the current Matrix room to the Telegram chat with the given " + "ID. The ID must be the prefixed version that you get with the `/id` " + "command of the Telegram-side bot.") +async def bridge(evt: CommandEvent) -> Dict: + if len(evt.args) == 0: + return await evt.reply("**Usage:** " + "`$cmdprefix+sp bridge [Matrix room ID]`") + room_id = MatrixRoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id + that_this = "This" if room_id == evt.room_id else "That" + + portal = po.Portal.get_by_mxid(room_id) + if portal: + return await evt.reply(f"{that_this} room is already a portal room.") + + if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"): + return await evt.reply(f"You do not have the permissions to bridge {that_this} room.") + + # The /id bot command provides the prefixed ID, so we assume + tgid_str = evt.args[0] + if tgid_str.startswith("-100"): + tgid = TelegramID(int(tgid_str[4:])) + peer_type = "channel" + elif tgid_str.startswith("-"): + tgid = TelegramID(-int(tgid_str)) + peer_type = "chat" + else: + return await evt.reply("That doesn't seem like a prefixed Telegram chat ID.\n\n" + "If you did not get the ID using the `/id` bot command, please " + "prefix channel IDs with `-100` and normal group IDs with `-`.\n\n" + "Bridging private chats to existing rooms is not allowed.") + + portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type) + if not portal.allow_bridging(): + return await evt.reply("This bridge doesn't allow bridging that Telegram chat.\n" + "If you're the bridge admin, try " + "`$cmdprefix+sp filter whitelist ` first.") + if portal.mxid: + has_portal_message = ( + "That Telegram chat already has a portal at " + f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). ") + if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"): + return await evt.reply(f"{has_portal_message}" + "Additionally, you do not have the permissions to unbridge " + "that room.") + evt.sender.command_status = { + "next": confirm_bridge, + "action": "Room bridging", + "mxid": portal.mxid, + "bridge_to_mxid": room_id, + "tgid": portal.tgid, + "peer_type": portal.peer_type, + } + return await evt.reply(f"{has_portal_message}" + "However, you have the permissions to unbridge that room.\n\n" + "To delete that portal completely and continue bridging, use " + "`$cmdprefix+sp delete-and-continue`. To unbridge the portal " + "without kicking Matrix users, use `$cmdprefix+sp unbridge-and-" + "continue`. To cancel, use `$cmdprefix+sp cancel`") + evt.sender.command_status = { + "next": confirm_bridge, + "action": "Room bridging", + "bridge_to_mxid": room_id, + "tgid": portal.tgid, + "peer_type": portal.peer_type, + } + return await evt.reply("That Telegram chat has no existing portal. To confirm bridging the " + "chat to this room, use `$cmdprefix+sp continue`") + + +async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal" + ) -> Tuple[bool, Optional[Coroutine[None, None, None]]]: + if not portal.mxid: + await evt.reply("The portal seems to have lost its Matrix room between you" + "calling `$cmdprefix+sp bridge` and this command.\n\n" + "Continuing without touching previous Matrix room...") + return True, None + elif evt.args[0] == "delete-and-continue": + return True, portal.cleanup_room(portal.main_intent, portal.mxid, + message="Portal deleted (moving to another room)") + elif evt.args[0] == "unbridge-and-continue": + return True, portal.cleanup_room(portal.main_intent, portal.mxid, + message="Room unbridged (portal moving to another room)", + puppets_only=True) + else: + await evt.reply( + "The chat you were trying to bridge already has a Matrix portal room.\n\n" + "Please use `$cmdprefix+sp delete-and-continue` or `$cmdprefix+sp unbridge-and-" + "continue` to either delete or unbridge the existing room (respectively) and " + "continue with the bridging.\n\n" + "If you changed your mind, use `$cmdprefix+sp cancel` to cancel.") + return False, None + + +async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]: + status = evt.sender.command_status + try: + portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"]) + bridge_to_mxid = status["bridge_to_mxid"] + except KeyError: + evt.sender.command_status = None + return await evt.reply("Fatal error: tgid or peer_type missing from command_status. " + "This shouldn't happen unless you're messing with the command " + "handler code.") + if "mxid" in status: + ok, coro = await cleanup_old_portal_while_bridging(evt, portal) + if not ok: + return None + elif coro: + ignore_coro(asyncio.ensure_future(coro, loop=evt.loop)) + await evt.reply("Cleaning up previous portal room...") + elif portal.mxid: + evt.sender.command_status = None + return await evt.reply("The portal seems to have created a Matrix room between you " + "calling `$cmdprefix+sp bridge` and this command.\n\n" + "Please start over by calling the bridge command again.") + elif evt.args[0] != "continue": + return await evt.reply("Please use `$cmdprefix+sp continue` to confirm the bridging or " + "`$cmdprefix+sp cancel` to cancel.") + + evt.sender.command_status = None + is_logged_in = await evt.sender.is_logged_in() + user = evt.sender if is_logged_in else evt.tgbot + try: + entity = await user.client.get_entity(portal.peer) + except Exception: + evt.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer) + if is_logged_in: + return await evt.reply("Failed to get info of telegram chat. " + "You are logged in, are you in that chat?") + else: + return await evt.reply("Failed to get info of telegram chat. " + "You're not logged in, is the relay bot in the chat?") + if isinstance(entity, (ChatForbidden, ChannelForbidden)): + if is_logged_in: + return await evt.reply("You don't seem to be in that chat.") + else: + return await evt.reply("The bot doesn't seem to be in that chat.") + + direct = False + + portal.mxid = bridge_to_mxid + portal.title, portal.about, levels = await get_initial_state(evt.az.intent, evt.room_id) + portal.photo_id = "" + portal.save() + + ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, + levels=levels), + loop=evt.loop)) + + return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.") diff --git a/mautrix_telegram/commands/portal/config.py b/mautrix_telegram/commands/portal/config.py new file mode 100644 index 00000000..2bec621e --- /dev/null +++ b/mautrix_telegram/commands/portal/config.py @@ -0,0 +1,132 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 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 typing import Dict, Awaitable +from io import StringIO + +from ...config import yaml +from ... import portal as po, user as u, util +from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT + +@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, + help_text="View or change per-portal settings.", + help_args="<`help`|_subcommand_> [...]") +async def config(evt: CommandEvent) -> None: + cmd = evt.args[0].lower() if len(evt.args) > 0 else "help" + if cmd not in ("view", "defaults", "set", "unset", "add", "del"): + await config_help(evt) + return + elif cmd == "defaults": + await config_defaults(evt) + return + + portal = po.Portal.get_by_mxid(evt.room_id) + if not portal: + await evt.reply("This is not a portal room.") + return + elif cmd == "view": + await config_view(evt, portal) + return + + key = evt.args[1] if len(evt.args) > 1 else None + value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 else None + if cmd == "set": + await config_set(evt, portal, key, value) + elif cmd == "unset": + await config_unset(evt, portal, key) + elif cmd == "add" or cmd == "del": + await config_add_del(evt, portal, key, value, cmd) + else: + return + portal.save() + + +def config_help(evt: CommandEvent) -> Awaitable[Dict]: + return evt.reply("""**Usage:** `$cmdprefix config [...]`. Subcommands: + +* **help** - View this help text. +* **view** - View the current config data. +* **defaults** - View the default config values. +* **set** <_key_> <_value_> - Set a config value. +* **unset** <_key_> - Remove a config value. +* **add** <_key_> <_value_> - Add a value to an array. +* **del** <_key_> <_value_> - Remove a value from an array. +""") + + +def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[Dict]: + stream = StringIO() + yaml.dump(portal.local_config, stream) + return evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}```") + + +def config_defaults(evt: CommandEvent) -> Awaitable[Dict]: + stream = StringIO() + yaml.dump({ + "edits_as_replies": evt.config["bridge.edits_as_replies"], + "bridge_notices": { + "default": evt.config["bridge.bridge_notices.default"], + "exceptions": evt.config["bridge.bridge_notices.exceptions"], + }, + "bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"], + "inline_images": evt.config["bridge.inline_images"], + "message_formats": evt.config["bridge.message_formats"], + "state_event_formats": evt.config["bridge.state_event_formats"], + "telegram_link_preview": evt.config["bridge.telegram_link_preview"], + }, stream) + return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```") + + +def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[Dict]: + if not key or value is None: + return evt.reply(f"**Usage:** `$cmdprefix+sp config set `") + elif util.recursive_set(portal.local_config, key, value): + return evt.reply(f"Successfully set the value of `{key}` to `{value}`.") + else: + return evt.reply(f"Failed to set value of `{key}`. " + "Does the path contain non-map types?") + + +def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Dict]: + if not key: + return evt.reply(f"**Usage:** `$cmdprefix+sp config unset `") + elif util.recursive_del(portal.local_config, key): + return evt.reply(f"Successfully deleted `{key}` from config.") + else: + return evt.reply(f"`{key}` not found in config.") + + +def config_add_del(evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str + ) -> Awaitable[Dict]: + if not key or value is None: + return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") + + arr = util.recursive_get(portal.local_config, key) + if not arr: + return evt.reply(f"`{key}` not found in config. " + f"Maybe do `$cmdprefix+sp config set {key} []` first?") + elif not isinstance(arr, list): + return evt.reply("`{key}` does not seem to be an array.") + elif cmd == "add": + if value in arr: + return evt.reply(f"The array at `{key}` already contains `{value}`.") + arr.append(value) + return evt.reply(f"Successfully added `{value}` to the array at `{key}`") + else: + if value not in arr: + return evt.reply(f"The array at `{key}` does not contain `{value}`.") + arr.remove(value) + return evt.reply(f"Successfully removed `{value}` from the array at `{key}`") diff --git a/mautrix_telegram/commands/portal/create_chat.py b/mautrix_telegram/commands/portal/create_chat.py new file mode 100644 index 00000000..98262591 --- /dev/null +++ b/mautrix_telegram/commands/portal/create_chat.py @@ -0,0 +1,59 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 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 typing import Dict + +from ... import portal as po +from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS +from .util import user_has_power_level, get_initial_state + + +@command_handler(help_section=SECTION_CREATING_PORTALS, + help_args="[_type_]", + help_text="Create a Telegram chat of the given type for the current Matrix room. " + "The type is either `group`, `supergroup` or `channel` (defaults to " + "`group`).") +async def create(evt: CommandEvent) -> Dict: + type = evt.args[0] if len(evt.args) > 0 else "group" + if type not in {"chat", "group", "supergroup", "channel"}: + return await evt.reply( + "**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`") + + if po.Portal.get_by_mxid(evt.room_id): + return await evt.reply("This is already a portal room.") + + if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"): + return await evt.reply("You do not have the permissions to bridge this room.") + + title, about, levels = await get_initial_state(evt.az.intent, evt.room_id) + if not title: + return await evt.reply("Please set a title before creating a Telegram chat.") + + supergroup = type == "supergroup" + type = { + "supergroup": "channel", + "channel": "channel", + "chat": "chat", + "group": "chat", + }[type] + + portal = po.Portal(tgid=None, mxid=evt.room_id, title=title, about=about, peer_type=type) + try: + await portal.create_telegram_chat(evt.sender, supergroup=supergroup) + except ValueError as e: + portal.delete() + return await evt.reply(e.args[0]) + return await evt.reply(f"Telegram chat created. ID: {portal.tgid}") diff --git a/mautrix_telegram/commands/portal/filter.py b/mautrix_telegram/commands/portal/filter.py new file mode 100644 index 00000000..5df42f5d --- /dev/null +++ b/mautrix_telegram/commands/portal/filter.py @@ -0,0 +1,95 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 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 typing import Dict, Optional + +from ... import portal as po +from .. import command_handler, CommandEvent, SECTION_ADMIN + + +@command_handler(needs_admin=True, + help_section=SECTION_ADMIN, + help_args="<`whitelist`|`blacklist`>", + help_text="Change whether the bridge will allow or disallow bridging rooms by " + "default.") +async def filter_mode(evt: CommandEvent) -> Dict: + try: + mode = evt.args[0] + if mode not in ("whitelist", "blacklist"): + raise ValueError() + except (IndexError, ValueError): + return await evt.reply("**Usage:** `$cmdprefix+sp filter-mode `") + + evt.config["bridge.filter.mode"] = mode + evt.config.save() + po.Portal.filter_mode = mode + if mode == "whitelist": + return await evt.reply("The bridge will now disallow bridging chats by default.\n" + "To allow bridging a specific chat, use" + "`!filter whitelist `.") + else: + return await evt.reply("The bridge will now allow bridging chats by default.\n" + "To disallow bridging a specific chat, use" + "`!filter blacklist `.") + + +@command_handler(needs_admin=True, + help_section=SECTION_ADMIN, + help_args="<`whitelist`|`blacklist`> <_chat ID_>", + help_text="Allow or disallow bridging a specific chat.") +async def filter(evt: CommandEvent) -> Optional[Dict]: + try: + action = evt.args[0] + if action not in ("whitelist", "blacklist", "add", "remove"): + raise ValueError() + + id_str = evt.args[1] + if id_str.startswith("-100"): + id = int(id_str[4:]) + elif id_str.startswith("-"): + id = int(id_str[1:]) + else: + id = int(id_str) + except (IndexError, ValueError): + return await evt.reply("**Usage:** `$cmdprefix+sp filter `") + + mode = evt.config["bridge.filter.mode"] + if mode not in ("blacklist", "whitelist"): + return await evt.reply(f"Unknown filter mode \"{mode}\". Please fix the bridge config.") + + list = evt.config["bridge.filter.list"] + + if action in ("blacklist", "whitelist"): + action = "add" if mode == action else "remove" + + def save() -> None: + evt.config["bridge.filter.list"] = list + evt.config.save() + po.Portal.filter_list = list + + if action == "add": + if id in list: + return await evt.reply(f"That chat is already {mode}ed.") + list.append(id) + save() + return await evt.reply(f"Chat ID added to {mode}.") + elif action == "remove": + if id not in list: + return await evt.reply(f"That chat is not {mode}ed.") + list.remove(id) + save() + return await evt.reply(f"Chat ID removed from {mode}.") + return None diff --git a/mautrix_telegram/commands/portal/misc.py b/mautrix_telegram/commands/portal/misc.py new file mode 100644 index 00000000..a5617848 --- /dev/null +++ b/mautrix_telegram/commands/portal/misc.py @@ -0,0 +1,127 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 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 typing import Dict + +from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError, + UsernameNotModifiedError, UsernameOccupiedError) + +from ... import portal as po +from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT, SECTION_MISC +from .util import user_has_power_level + + +@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False, + help_section=SECTION_MISC, + help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.") +async def sync_state(evt: CommandEvent) -> Dict: + portal = po.Portal.get_by_mxid(evt.room_id) + if not portal: + return await evt.reply("This is not a portal room.") + elif not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"): + return await evt.reply(f"You do not have the permissions to synchronize this room.") + + await portal.sync_matrix_members() + await evt.reply("Synchronization complete") + + +@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False, + help_section=SECTION_MISC, + help_text="Get the ID of the Telegram chat where this room is bridged.") +async def id(evt: CommandEvent) -> Dict: + portal = po.Portal.get_by_mxid(evt.room_id) + if not portal: + return await evt.reply("This is not a portal room.") + tgid = portal.tgid + if portal.peer_type == "chat": + tgid = -tgid + elif portal.peer_type == "channel": + tgid = f"-100{tgid}" + await evt.reply(f"This room is bridged to Telegram chat ID `{tgid}`.") + + +@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, + help_text="Get a Telegram invite link to the current chat.") +async def invite_link(evt: CommandEvent) -> Dict: + portal = po.Portal.get_by_mxid(evt.room_id) + if not portal: + return await evt.reply("This is not a portal room.") + + if portal.peer_type == "user": + return await evt.reply("You can't invite users to private chats.") + + try: + link = await portal.get_invite_link(evt.sender) + return await evt.reply(f"Invite link to {portal.title}: {link}") + except ValueError as e: + return await evt.reply(e.args[0]) + except ChatAdminRequiredError: + return await evt.reply("You don't have the permission to create an invite link.") + + +@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, + help_text="Upgrade a normal Telegram group to a supergroup.") +async def upgrade(evt: CommandEvent) -> Dict: + portal = po.Portal.get_by_mxid(evt.room_id) + if not portal: + return await evt.reply("This is not a portal room.") + elif portal.peer_type == "channel": + return await evt.reply("This is already a supergroup or a channel.") + elif portal.peer_type == "user": + return await evt.reply("You can't upgrade private chats.") + + try: + await portal.upgrade_telegram_chat(evt.sender) + return await evt.reply(f"Group upgraded to supergroup. New ID: -100{portal.tgid}") + except ChatAdminRequiredError: + return await evt.reply("You don't have the permission to upgrade this group.") + except ValueError as e: + return await evt.reply(e.args[0]) + + +@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, + help_args="<_name_|`-`>", + help_text="Change the username of a supergroup/channel. " + "To disable, use a dash (`-`) as the name.") +async def group_name(evt: CommandEvent) -> Dict: + if len(evt.args) == 0: + return await evt.reply("**Usage:** `$cmdprefix+sp group-name `") + + portal = po.Portal.get_by_mxid(evt.room_id) + if not portal: + return await evt.reply("This is not a portal room.") + elif portal.peer_type != "channel": + return await evt.reply("Only channels and supergroups have usernames.") + + try: + await portal.set_telegram_username(evt.sender, + evt.args[0] if evt.args[0] != "-" else "") + if portal.username: + return await evt.reply(f"Username of channel changed to {portal.username}.") + else: + return await evt.reply(f"Channel is now private.") + except ChatAdminRequiredError: + return await evt.reply( + "You don't have the permission to set the username of this channel.") + except UsernameNotModifiedError: + if portal.username: + return await evt.reply("That is already the username of this channel.") + else: + return await evt.reply("This channel is already private") + except UsernameOccupiedError: + return await evt.reply("That username is already in use.") + except UsernameInvalidError: + return await evt.reply("Invalid username") diff --git a/mautrix_telegram/commands/portal/unbridge.py b/mautrix_telegram/commands/portal/unbridge.py new file mode 100644 index 00000000..95ff90ea --- /dev/null +++ b/mautrix_telegram/commands/portal/unbridge.py @@ -0,0 +1,97 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 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 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 + + +async def _get_portal_and_check_permission(evt: CommandEvent, permission: str, + action: Optional[str] = None + ) -> Optional[po.Portal]: + room_id = MatrixRoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id + + portal = po.Portal.get_by_mxid(room_id) + if not portal: + that_this = "This" if room_id == evt.room_id else "That" + await evt.reply(f"{that_this} is not a portal room.") + return None + + if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, permission): + action = action or f"{permission.replace('_', ' ')}s" + await evt.reply(f"You do not have the permissions to {action} that portal.") + return None + return portal + + +def _get_portal_murder_function(action: str, room_id: str, function: Callable, command: str, + completed_message: str) -> Dict: + async def post_confirm(confirm) -> Optional[Dict]: + confirm.sender.command_status = None + if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}": + await function() + if confirm.room_id != room_id: + return await confirm.reply(completed_message) + else: + return await confirm.reply(f"{action} cancelled.") + return None + + return { + "next": post_confirm, + "action": action, + } + + +@command_handler(needs_auth=False, needs_puppeting=False, + help_section=SECTION_PORTAL_MANAGEMENT, + help_text="Remove all users from the current portal room and forget the portal. " + "Only works for group chats; to delete a private chat portal, simply " + "leave the room.") +async def delete_portal(evt: CommandEvent) -> Optional[Dict]: + portal = await _get_portal_and_check_permission(evt, "unbridge") + if not portal: + return None + + evt.sender.command_status = _get_portal_murder_function("Portal deletion", portal.mxid, + portal.cleanup_and_delete, "delete", + "Portal successfully deleted.") + return await evt.reply("Please confirm deletion of portal " + f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) " + f"to Telegram chat \"{portal.title}\" " + "by typing `$cmdprefix+sp confirm-delete`" + "\n\n" + "**WARNING:** If the bridge bot has the power level to do so, **this " + "will kick ALL users** in the room. If you just want to remove the " + "bridge, use `$cmdprefix+sp unbridge` instead.") + + +@command_handler(needs_auth=False, needs_puppeting=False, + help_section=SECTION_PORTAL_MANAGEMENT, + help_text="Remove puppets from the current portal room and forget the portal.") +async def unbridge(evt: CommandEvent) -> Optional[Dict]: + portal = await _get_portal_and_check_permission(evt, "unbridge") + if not portal: + return None + + evt.sender.command_status = _get_portal_murder_function("Room unbridging", portal.mxid, + portal.unbridge, "unbridge", + "Room successfully unbridged.") + return await evt.reply(f"Please confirm unbridging chat \"{portal.title}\" from room " + f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) " + "by typing `$cmdprefix+sp confirm-unbridge`") diff --git a/mautrix_telegram/commands/portal/util.py b/mautrix_telegram/commands/portal/util.py new file mode 100644 index 00000000..4846db5c --- /dev/null +++ b/mautrix_telegram/commands/portal/util.py @@ -0,0 +1,40 @@ +from typing import Dict, Tuple + +from mautrix_appservice import MatrixRequestError, IntentAPI + +from ... import user as u + + +async def get_initial_state(intent: IntentAPI, room_id: str) -> Tuple[str, str, Dict]: + state = await intent.get_room_state(room_id) + title = None + about = None + levels = None + for event in state: + try: + if event["type"] == "m.room.name": + title = event["content"]["name"] + elif event["type"] == "m.room.topic": + about = event["content"]["topic"] + elif event["type"] == "m.room.power_levels": + levels = event["content"] + elif event["type"] == "m.room.canonical_alias": + title = title or event["content"]["alias"] + except KeyError: + # Some state event probably has empty content + pass + return title, about, levels + + +async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50 + ) -> bool: + if sender.is_admin: + return True + # Make sure the state store contains the power levels. + try: + await intent.get_power_levels(room) + except MatrixRequestError: + return False + return intent.state_store.has_power_level(room, sender.mxid, + event=f"net.maunium.telegram.{event}", + default=default) diff --git a/mautrix_telegram/commands/telegram/__init__.py b/mautrix_telegram/commands/telegram/__init__.py new file mode 100644 index 00000000..1eb1e199 --- /dev/null +++ b/mautrix_telegram/commands/telegram/__init__.py @@ -0,0 +1 @@ +from . import account, auth, misc diff --git a/mautrix_telegram/commands/telegram/account.py b/mautrix_telegram/commands/telegram/account.py new file mode 100644 index 00000000..5d273729 --- /dev/null +++ b/mautrix_telegram/commands/telegram/account.py @@ -0,0 +1,49 @@ +# -*- coding: future_fstrings -*- +# mautrix-telegram - A Matrix-Telegram puppeting bridge +# Copyright (C) 2018 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 typing import Dict, Optional + +from telethon.errors import UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError +from telethon.tl.functions.account import UpdateUsernameRequest + +from mautrix_telegram.commands import command_handler, CommandEvent, SECTION_AUTH + + +@command_handler(needs_auth=True, + help_section=SECTION_AUTH, + help_text="Change your Telegram username") +async def username(evt: CommandEvent) -> Optional[Dict]: + if len(evt.args) == 0: + return await evt.reply("**Usage:** `$cmdprefix+sp username `") + if evt.sender.is_bot: + return await evt.reply("Bots can't set their own username.") + new_name = evt.args[0] + if new_name == "-": + new_name = "" + try: + await evt.sender.client(UpdateUsernameRequest(username=new_name)) + except UsernameInvalidError: + return await evt.reply("Invalid username. Usernames must be between 5 and 30 alphanumeric " + "characters.") + except UsernameNotModifiedError: + return await evt.reply("That is your current username.") + except UsernameOccupiedError: + return await evt.reply("That username is already in use.") + await evt.sender.update_info() + if not evt.sender.username: + await evt.reply("Username removed") + else: + await evt.reply(f"Username changed to {evt.sender.username}") diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/telegram/auth.py similarity index 71% rename from mautrix_telegram/commands/auth.py rename to mautrix_telegram/commands/telegram/auth.py index 5b089bfa..02dab059 100644 --- a/mautrix_telegram/commands/auth.py +++ b/mautrix_telegram/commands/telegram/auth.py @@ -21,13 +21,11 @@ AccessTokenExpiredError, AccessTokenInvalidError, FirstNameInvalidError, FloodWaitError, PasswordHashInvalidError, PhoneCodeExpiredError, PhoneCodeInvalidError, PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError, - PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError, - UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError) -from telethon.tl.functions.account import UpdateUsernameRequest + PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError) -from . import command_handler, CommandEvent, SECTION_AUTH -from .. import puppet as pu, user as u -from ..util import format_duration, ignore_coro +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 @command_handler(needs_auth=False, @@ -56,87 +54,6 @@ async def ping_bot(evt: CommandEvent) -> Optional[Dict]: "To use the bot, simply invite it to a portal room.") -@command_handler(needs_auth=True, needs_matrix_puppeting=True, - help_section=SECTION_AUTH, - help_text="Revert your Telegram account's Matrix puppet to use the default Matrix " - "account.") -async def logout_matrix(evt: CommandEvent) -> Optional[Dict]: - puppet = pu.Puppet.get(evt.sender.tgid) - if not puppet.is_real_user: - return await evt.reply("You are not logged in with your Matrix account.") - await puppet.switch_mxid(None, None) - return await evt.reply("Reverted your Telegram account's Matrix puppet back to the default.") - - -@command_handler(needs_auth=True, management_only=True, needs_matrix_puppeting=True, - help_section=SECTION_AUTH, - help_text="Replace your Telegram account's Matrix puppet with your own Matrix " - "account") -async def login_matrix(evt: CommandEvent) -> Optional[Dict]: - puppet = pu.Puppet.get(evt.sender.tgid) - if puppet.is_real_user: - return await evt.reply("You have already logged in with your Matrix account. " - "Log out with `$cmdprefix+sp logout-matrix` first.") - allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True) - if allow_matrix_login: - evt.sender.command_status = { - "next": enter_matrix_token, - "action": "Matrix login", - } - if evt.config["appservice.public.enabled"]: - prefix = evt.config["appservice.public.external"] - token = evt.public_website.make_token(evt.sender.mxid, "/matrix-login") - url = f"{prefix}/matrix-login?token={token}" - if allow_matrix_login: - return await evt.reply( - "This bridge instance allows you to log in inside or outside Matrix.\n\n" - "If you would like to log in within Matrix, please send your Matrix access token " - "here.\n" - f"If you would like to log in outside of Matrix, [click here]({url}).\n\n" - "Logging in outside of Matrix is recommended, because in-Matrix login would save " - "your access token in the message history.") - return await evt.reply("This bridge instance does not allow logging in inside Matrix.\n\n" - f"Please visit [the login page]({url}) to log in.") - elif allow_matrix_login: - return await evt.reply( - "This bridge instance does not allow you to log in outside of Matrix.\n\n" - "Please send your Matrix access token here to log in.") - return await evt.reply("This bridge instance has been configured to not allow logging in.") - - -@command_handler(needs_auth=True, needs_matrix_puppeting=True, - help_section=SECTION_AUTH, - help_text="Pings the server with the stored matrix authentication") -async def ping_matrix(evt: CommandEvent) -> Optional[Dict]: - puppet = pu.Puppet.get(evt.sender.tgid) - if not puppet.is_real_user: - return await evt.reply("You are not logged in with your Matrix account.") - resp = await puppet.init_custom_mxid() - if resp == pu.PuppetError.InvalidAccessToken: - return await evt.reply("Your access token is invalid.") - elif resp == pu.PuppetError.Success: - return await evt.reply("Your Matrix login is working.") - return await evt.reply(f"Unknown response while checking your Matrix login: {resp}.") - - -async def enter_matrix_token(evt: CommandEvent) -> Dict: - evt.sender.command_status = None - - puppet = pu.Puppet.get(evt.sender.tgid) - if puppet.is_real_user: - return await evt.reply("You have already logged in with your Matrix account. " - "Log out with `$cmdprefix+sp logout-matrix` first.") - - resp = await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) - if resp == pu.PuppetError.OnlyLoginSelf: - return await evt.reply("You can only log in as your own Matrix user.") - elif resp == pu.PuppetError.InvalidAccessToken: - return await evt.reply("Failed to verify access token.") - assert resp == pu.PuppetError.Success, "Encountered an unhandled PuppetError." - return await evt.reply( - f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}.") - - @command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, help_args="<_phone_> <_full name_>", @@ -375,30 +292,3 @@ async def logout(evt: CommandEvent) -> Optional[Dict]: if await evt.sender.log_out(): return await evt.reply("Logged out successfully.") return await evt.reply("Failed to log out.") - - -@command_handler(needs_auth=True, - help_section=SECTION_AUTH, - help_text="Change your Telegram username") -async def username(evt: CommandEvent) -> Optional[Dict]: - if len(evt.args) == 0: - return await evt.reply("**Usage:** `$cmdprefix+sp username `") - if evt.sender.is_bot: - return await evt.reply("Bots can't set their own username.") - new_name = evt.args[0] - if new_name == "-": - new_name = "" - try: - await evt.sender.client(UpdateUsernameRequest(username=new_name)) - except UsernameInvalidError: - return await evt.reply("Invalid username. Usernames must be between 5 and 30 alphanumeric " - "characters.") - except UsernameNotModifiedError: - return await evt.reply("That is your current username.") - except UsernameOccupiedError: - return await evt.reply("That username is already in use.") - await evt.sender.update_info() - if not evt.sender.username: - await evt.reply("Username removed") - else: - await evt.reply(f"Username changed to {evt.sender.username}") diff --git a/mautrix_telegram/commands/telegram.py b/mautrix_telegram/commands/telegram/misc.py similarity index 96% rename from mautrix_telegram/commands/telegram.py rename to mautrix_telegram/commands/telegram/misc.py index 00c5f3f0..01942d17 100644 --- a/mautrix_telegram/commands/telegram.py +++ b/mautrix_telegram/commands/telegram/misc.py @@ -27,10 +27,10 @@ GetBotCallbackAnswerRequest) from telethon.tl.functions.channels import JoinChannelRequest -from .. import puppet as pu, portal as po -from ..db import Message as DBMessage -from ..types import TelegramID -from . import command_handler, CommandEvent, SECTION_MISC, SECTION_CREATING_PORTALS +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 @command_handler(help_section=SECTION_MISC, @@ -71,14 +71,13 @@ async def search(evt: CommandEvent) -> Optional[Dict]: return await evt.reply("\n".join(reply)) -@command_handler(name="pm", - help_section=SECTION_CREATING_PORTALS, +@command_handler(help_section=SECTION_CREATING_PORTALS, help_args="<_identifier_>", help_text="Open a private chat with the given Telegram user. The identifier is " "either the internal user ID, the username or the phone number. " "**N.B.** The phone numbers you start chats with must already be in " "your contacts.") -async def private_message(evt: CommandEvent) -> Optional[Dict]: +async def pm(evt: CommandEvent) -> Optional[Dict]: if len(evt.args) == 0: return await evt.reply("**Usage:** `$cmdprefix+sp pm `") diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py index c5206121..2c68abcd 100644 --- a/mautrix_telegram/config.py +++ b/mautrix_telegram/config.py @@ -209,7 +209,6 @@ def copy_dict(from_path, to_path=None, override_existing_map=True) -> None: copy("bridge.inline_images") copy("bridge.plaintext_highlights") copy("bridge.public_portals") - copy("bridge.native_stickers") copy("bridge.catch_up") copy("bridge.sync_with_custom_puppets") copy("bridge.telegram_link_preview") diff --git a/mautrix_telegram/db/puppet.py b/mautrix_telegram/db/puppet.py index 3fde9773..4efb90be 100644 --- a/mautrix_telegram/db/puppet.py +++ b/mautrix_telegram/db/puppet.py @@ -63,7 +63,7 @@ def get_by_tgid(cls, tgid: TelegramID) -> Optional['Puppet']: return cls._select_one_or_none(cls.c.id == tgid) @classmethod - def get_by_custom_mxid(cls, mxid: MatrixRoomID) -> Optional['Puppet']: + def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']: return cls._select_one_or_none(cls.c.custom_mxid == mxid) @classmethod diff --git a/mautrix_telegram/db/user.py b/mautrix_telegram/db/user.py index 5bbf5ff0..1bcd9c36 100644 --- a/mautrix_telegram/db/user.py +++ b/mautrix_telegram/db/user.py @@ -53,7 +53,7 @@ def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']: return cls._select_one_or_none(cls.c.tgid == tgid) @classmethod - def get_by_mxid(cls, mxid: MatrixRoomID) -> Optional['User']: + def get_by_mxid(cls, mxid: MatrixUserID) -> Optional['User']: return cls._select_one_or_none(cls.c.mxid == mxid) @classmethod @@ -79,8 +79,9 @@ def contacts(self) -> Iterable[TelegramID]: @contacts.setter def contacts(self, puppets: Iterable[TelegramID]) -> None: self.db.execute(Contact.t.delete().where(Contact.c.user == self.tgid)) - self.db.execute(Contact.t.insert(), [{"user": self.tgid, "contact": tgid} - for tgid in puppets]) + if puppets: + self.db.execute(Contact.t.insert(), [{"user": self.tgid, "contact": tgid} + for tgid in puppets]) @property def portals(self) -> Iterable[Tuple[TelegramID, TelegramID]]: @@ -92,12 +93,18 @@ def portals(self) -> Iterable[Tuple[TelegramID, TelegramID]]: @portals.setter def portals(self, portals: Iterable[Tuple[TelegramID, TelegramID]]) -> None: self.db.execute(UserPortal.t.delete().where(UserPortal.c.user == self.tgid)) - self.db.execute(UserPortal.t.insert(), - [{ - "user": self.tgid, - "portal": tgid, - "portal_receiver": tg_receiver - } for tgid, tg_receiver in portals]) + if portals: + self.db.execute(UserPortal.t.insert(), + [{ + "user": self.tgid, + "portal": tgid, + "portal_receiver": tg_receiver + } for tgid, tg_receiver in portals]) + + def delete(self) -> None: + super().delete() + self.portals = None + self.contacts = None class UserPortal(Base): diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 29606ab1..7d4b05ec 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -1409,7 +1409,7 @@ async def handle_telegram_document(self, source: 'AbstractUser', intent: IntentA "external_url": self.get_external_url(evt) } - if attrs["is_sticker"] and self.get_config("native_stickers"): + if attrs["is_sticker"]: return await intent.send_sticker(**kwargs) mime_type = info["mimetype"] diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py index 4a05345d..e92b3cd9 100644 --- a/mautrix_telegram/puppet.py +++ b/mautrix_telegram/puppet.py @@ -30,7 +30,6 @@ from .types import MatrixUserID, TelegramID from .db import Puppet as DBPuppet -from .util import ignore_coro from . import util if TYPE_CHECKING: @@ -82,6 +81,7 @@ def __init__(self, self.default_mxid_intent = self.az.intent.user(self.default_mxid) self.intent = self._fresh_intent() # type: IntentAPI + self.sync_task = None # type: Optional[asyncio.Future] self.cache[id] = self if self.custom_mxid: @@ -154,7 +154,7 @@ async def init_custom_mxid(self) -> PuppetError: return PuppetError.OnlyLoginSelf return PuppetError.InvalidAccessToken if config["bridge.sync_with_custom_puppets"]: - ignore_coro(asyncio.ensure_future(self.sync(), loop=self.loop)) + self.sync_task = asyncio.ensure_future(self.sync(), loop=self.loop) return PuppetError.Success async def leave_rooms_with_default_user(self) -> None: @@ -236,6 +236,8 @@ def handle_sync(self, presence: List, ephemeral: Dict) -> None: async def sync(self) -> None: try: await self._sync() + except asyncio.CancelledError: + self.log.info("Syncing cancelled") except Exception: self.log.exception("Fatal error syncing") diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py index dcb571cb..69642468 100644 --- a/mautrix_telegram/user.py +++ b/mautrix_telegram/user.py @@ -136,13 +136,13 @@ def save(self) -> None: self.db_instance.update(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone, saved_contacts=self.saved_contacts) - def delete(self) -> None: + def delete(self, delete_db: bool = True) -> None: try: del self.by_mxid[self.mxid] del self.by_tgid[self.tgid] except KeyError: pass - if self._db_instance: + if delete_db and self._db_instance: self._db_instance.delete() @classmethod @@ -316,7 +316,7 @@ def unregister_portal(self, portal: po.Portal) -> None: async def needs_relaybot(self, portal: po.Portal) -> bool: return not await self.is_logged_in() or ( - self.is_bot and portal.tgid_full not in self.portals) + (portal.has_bot or self.bot) and portal.tgid_full not in self.portals) def _hash_contacts(self) -> int: acc = 0 @@ -328,7 +328,7 @@ async def sync_contacts(self) -> None: response = await self.client(GetContactsRequest(hash=self._hash_contacts())) if isinstance(response, ContactsNotModified): return - self.log.debug("Updating contacts...") + self.log.debug(f"Updating contacts of {self.name}...") self.contacts = [] self.saved_contacts = response.saved_count for user in response.users: diff --git a/mautrix_telegram/web/common/auth_api.py b/mautrix_telegram/web/common/auth_api.py index 803df2e7..cf326cbc 100644 --- a/mautrix_telegram/web/common/auth_api.py +++ b/mautrix_telegram/web/common/auth_api.py @@ -24,7 +24,7 @@ from telethon.errors import * -from ...commands.auth import enter_password +from ...commands.telegram.auth import enter_password from ...util import format_duration, ignore_coro from ...puppet import Puppet, PuppetError from ...user import User diff --git a/mautrix_telegram/web/provisioning/__init__.py b/mautrix_telegram/web/provisioning/__init__.py index eec0b3c4..995ac2cf 100644 --- a/mautrix_telegram/web/provisioning/__init__.py +++ b/mautrix_telegram/web/provisioning/__init__.py @@ -28,7 +28,7 @@ from ...user import User from ...portal import Portal from ...util import ignore_coro -from ...commands.portal import user_has_power_level, get_initial_state +from ...commands.portal.util import user_has_power_level, get_initial_state from ..common import AuthAPI if TYPE_CHECKING: