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: