From fc234614457d78ef7e82c934e02b95ec317670a6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 24 Sep 2018 16:01:16 +0300 Subject: [PATCH 1/5] Add room specific settings. Probably broken --- example-config.yaml | 39 +++--- mautrix_telegram/abstract_user.py | 4 +- mautrix_telegram/config.py | 8 +- mautrix_telegram/db.py | 2 + .../formatter/from_matrix/__init__.py | 7 +- mautrix_telegram/portal.py | 117 +++++++++++------- 6 files changed, 111 insertions(+), 66 deletions(-) diff --git a/example-config.yaml b/example-config.yaml index 55ec2968..b57da198 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -95,15 +95,6 @@ bridge: - username - phone number - # 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 - # Highlight changed/added parts in edits. Requires lxml. - highlight_edits: false - # Whether or not Matrix bot messages (type m.notice) should be bridged. - bridge_notices: true - # Whether to bridge Telegram bot messages as m.notices or m.texts. - bot_messages_as_notices: true # Maximum number of members to sync per portal when starting up. Other members will be # synced when they send messages. The maximum is 10000, after which the Telegram server # will not send any more members. @@ -119,19 +110,12 @@ bridge: # 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 - # Use inline images instead of m.image to make rich captions possible. - # N.B. Inline images are not supported on all clients (e.g. Riot iOS). - inline_images: false # Whether or not to bridge plaintext highlights. # 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 # Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix. public_portals: true - # 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 # Whether or not to fetch and handle Telegram updates at startup from the time the bridge was down. # Currently only works for private chats and normal groups. catch_up: false @@ -149,6 +133,29 @@ 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 + # Highlight changed/added parts in edits. Requires lxml. + highlight_edits: 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 m.image to make rich captions possible. + # 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. # # Telegram doesn't have built-in emotes, so the m.emote format is also used for non-relaybot users. diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py index e8c3f444..1766e7b7 100644 --- a/mautrix_telegram/abstract_user.py +++ b/mautrix_telegram/abstract_user.py @@ -35,7 +35,7 @@ from alchemysession import AlchemySessionContainer from . import portal as po, puppet as pu, __version__ from .db import Message as DBMessage -from .types import TelegramID +from .types import TelegramID, MatrixUserID from .tgclient import MautrixTelegramClient if TYPE_CHECKING: @@ -69,7 +69,7 @@ class AbstractUser(ABC): self.relaybot_whitelisted = False # type: bool self.client = None # type: MautrixTelegramClient self.tgid = None # type: TelegramID - self.mxid = None # type: str + self.mxid = None # type: MatrixUserID self.is_relaybot = False # type: bool self.is_bot = False # type: bool self.relaybot = None # type: Optional[Bot] diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py index 7f0029d3..8e374a38 100644 --- a/mautrix_telegram/config.py +++ b/mautrix_telegram/config.py @@ -183,7 +183,13 @@ class Config(DictWithRecursion): copy("bridge.edits_as_replies") copy("bridge.highlight_edits") - copy("bridge.bridge_notices") + if isinstance(self["bridge.bridge_notices"], bool): + base["bridge.bridge_notices"] = { + "default": self["bridge.bridge_notices"], + "exceptions": ["@importantbot:example.com"], + } + else: + copy("bridge.bridge_notices") copy("bridge.bot_messages_as_notices") copy("bridge.max_initial_member_sync") copy("bridge.sync_channel_members") diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py index ddd0d701..b17ef5e8 100644 --- a/mautrix_telegram/db.py +++ b/mautrix_telegram/db.py @@ -39,6 +39,8 @@ class Portal(Base): # Matrix portal information mxid = Column(String, unique=True, nullable=True) # type: Optional[MatrixRoomID] + config = Column(Text, nullable=True) + # Telegram chat metadata username = Column(String, nullable=True) title = Column(String, nullable=True) diff --git a/mautrix_telegram/formatter/from_matrix/__init__.py b/mautrix_telegram/formatter/from_matrix/__init__.py index 99a9a70a..f5e49d88 100644 --- a/mautrix_telegram/formatter/from_matrix/__init__.py +++ b/mautrix_telegram/formatter/from_matrix/__init__.py @@ -14,7 +14,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING +from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING, Dict, Any import re import logging @@ -22,6 +22,7 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M TypeMessageEntity) from ... import puppet as pu +from ...types import TelegramID, MatrixRoomID from ...db import Message as DBMessage from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html, trim_reply_fallback_text) @@ -90,8 +91,8 @@ def matrix_to_telegram(html: str) -> ParsedMessage: raise FormatError(f"Failed to convert Matrix format: {html}") from e -def matrix_reply_to_telegram(content: dict, tg_space: int, room_id: Optional[str] = None - ) -> Optional[int]: +def matrix_reply_to_telegram(content: Dict[str, Any], tg_space: TelegramID, + room_id: Optional[MatrixRoomID] = None) -> Optional[TelegramID]: try: reply = content["m.relates_to"]["m.in_reply_to"] room_id = room_id or reply["room_id"] diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index 487b0ec1..b6cab857 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -14,7 +14,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Awaitable, Dict, List, Optional, Pattern, Tuple, Union, cast, TYPE_CHECKING +from typing import Awaitable, Dict, List, Optional, Pattern, Tuple, Union, cast, TYPE_CHECKING, Any from collections import deque from datetime import datetime from string import Template @@ -25,6 +25,7 @@ import mimetypes import unicodedata import hashlib import logging +import json import re import magic @@ -79,8 +80,8 @@ config = None # type: Config TypeMessage = Union[Message, MessageService] TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant] -DedupMXID = Tuple[str, int] -InviteList = Union[str, List[str]] +DedupMXID = Tuple[MatrixEventID, TelegramID] +InviteList = Union[MatrixUserID, List[MatrixUserID]] class Portal: @@ -90,10 +91,13 @@ class Portal: bot = None # type: Bot loop = None # type: asyncio.AbstractEventLoop + # Config cache filter_mode = None # type: str filter_list = None # type: List[str] - bridge_notices = False # type: bool + public_portals = False # type: bool + max_initial_member_sync = -1 # type: int + sync_channel_members = True # type: bool dedup_pre_db_check = False # type: bool dedup_cache_queue_length = 20 # type: int @@ -102,6 +106,7 @@ class Portal: mx_alias_regex = None # type: Pattern hs_domain = None # type: str + # Instance cache by_mxid = {} # type: Dict[MatrixRoomID, Portal] by_tgid = {} # type: Dict[Tuple[TelegramID, TelegramID], Portal] @@ -109,7 +114,7 @@ class Portal: mxid: Optional[MatrixRoomID] = None, username: Optional[str] = None, megagroup: Optional[bool] = False, title: Optional[str] = None, about: Optional[str] = None, photo_id: Optional[str] = None, - db_instance: DBPortal = None) -> None: + config: Optional[str] = None, db_instance: DBPortal = None) -> None: self.mxid = mxid # type: Optional[MatrixRoomID] self.tgid = tgid # type: TelegramID self.tg_receiver = tg_receiver or tgid # type: TelegramID @@ -119,6 +124,7 @@ class Portal: self.title = title # type: Optional[str] self.about = about # type: str self.photo_id = photo_id # type: str + self.local_config = json.loads(config or "{}") # type: Dict[str, Any] self._db_instance = db_instance # type: DBPortal self.deleted = False # type: bool @@ -248,7 +254,7 @@ class Portal: try: found_mxid = self._dedup_mxid[evt_hash] except KeyError: - return "None", 0 + return MatrixEventID("None"), TelegramID(0) if found_mxid != expected_mxid: return found_mxid @@ -346,7 +352,7 @@ class Portal: self.megagroup = entity.megagroup if self.peer_type == "channel" and entity.username: - public = config["bridge.public_portals"] + public = Portal.public_portals alias = self._get_alias_localpart(entity.username) self.username = entity.username else: @@ -451,7 +457,7 @@ class Portal: # * The member sync count is limited, because then we might ignore some members. # * It's a channel, because non-admins don't have access to the member list. trust_member_list = (len(allowed_tgids) < 9900 - and config["bridge.max_initial_member_sync"] == -1 + and Portal.max_initial_member_sync == -1 and (self.megagroup or self.peer_type != "channel")) if trust_member_list: joined_mxids = cast(List[MatrixUserID], @@ -533,7 +539,7 @@ class Portal: self.username = username or None if self.username: await self.main_intent.add_room_alias(self.mxid, self._get_alias_localpart()) - if config["bridge.public_portals"]: + if Portal.public_portals: await self.main_intent.set_join_rule(self.mxid, "public") else: await self.main_intent.set_join_rule(self.mxid, "invite") @@ -593,10 +599,10 @@ class Portal: chat = await user.client(GetFullChatRequest(chat_id=self.tgid)) return chat.users, chat.full_chat.participants.participants elif self.peer_type == "channel": - if not self.megagroup and not config["bridge.sync_channel_members"]: + if not self.megagroup and not Portal.sync_channel_members: return [], [] - limit = config["bridge.max_initial_member_sync"] + limit = Portal.max_initial_member_sync if limit == 0: return [], [] @@ -712,9 +718,15 @@ class Portal: else: return "" + def get_config(self, key: str) -> Any: + local = self.local_config.get("state_event_formats", None) + if local is not None: + return local + return config[f"bridge.{key}"] + async def _get_state_change_message(self, event: str, user: 'u.User', arguments: Optional[Dict] = None) -> Optional[Dict]: - tpl = config[f"bridge.state_event_formats.{event}"] + tpl = self.get_config("state_event_formats").get(event, "") if len(tpl) == 0: # Empty format means they don't want the message return None @@ -731,7 +743,7 @@ class Portal: } async def name_change_matrix(self, user: 'u.User', displayname: str, prev_displayname: str, - event_id: str) -> None: + event_id: MatrixEventID) -> None: async with self.require_send_lock(self.bot.tgid): message = await self._get_state_change_message( "name_change", user, @@ -805,7 +817,7 @@ class Portal: channel = await self.get_input_entity(user) await user.client(LeaveChannelRequest(channel=channel)) - async def join_matrix(self, user: 'u.User', event_id: str) -> None: + async def join_matrix(self, user: 'u.User', event_id: MatrixEventID) -> None: if await user.needs_relaybot(self): async with self.require_send_lock(self.bot.tgid): message = await self._get_state_change_message("join", user) @@ -824,14 +836,14 @@ class Portal: # We'll just assume the user is already in the chat. pass - async def _apply_msg_format(self, sender: 'u.User', msgtype: str, message: Dict) -> None: + async def _apply_msg_format(self, sender: 'u.User', msgtype: str, message: Dict[str, Any] + ) -> None: if "formatted_body" not in message: message["format"] = "org.matrix.custom.html" message["formatted_body"] = escape_html(message.get("body", "")) body = message["formatted_body"] - tpl = config["bridge.message_formats"].get(msgtype, - "$sender_displayname: $message") + tpl = config.get("message_formats", {}).get(msgtype, "$sender_displayname: $message") displayname = await self.get_displayname(sender) tpl_args = dict(sender_mxid=sender.mxid, sender_username=sender.mxid_localpart, @@ -840,7 +852,7 @@ class Portal: message["formatted_body"] = Template(tpl).safe_substitute(tpl_args) async def _pre_process_matrix_message(self, sender: 'u.User', use_relaybot: bool, - message: dict) -> None: + message: Dict[str, Any]) -> None: msgtype = message.get("msgtype", "m.text") if msgtype == "m.emote": await self._apply_msg_format(sender, msgtype, message) @@ -849,7 +861,8 @@ class Portal: await self._apply_msg_format(sender, msgtype, message) @staticmethod - def _matrix_event_to_entities(event: Dict) -> Tuple[str, Optional[List[TypeMessageEntity]]]: + def _matrix_event_to_entities(event: Dict[str, Any]) -> Tuple[ + str, Optional[List[TypeMessageEntity]]]: try: if event.get("format", None) == "org.matrix.custom.html": message, entities = formatter.matrix_to_telegram(event.get("formatted_body", "")) @@ -859,7 +872,7 @@ class Portal: message, entities = None, None return message, entities - def require_send_lock(self, user_id: int) -> asyncio.Lock: + def require_send_lock(self, user_id: TelegramID) -> asyncio.Lock: if user_id is None: raise ValueError("Required send lock for none id") try: @@ -868,7 +881,7 @@ class Portal: self._send_locks[user_id] = asyncio.Lock() return self._send_locks[user_id] - def optional_send_lock(self, user_id: int) -> Optional[asyncio.Lock]: + def optional_send_lock(self, user_id: TelegramID) -> Optional[asyncio.Lock]: if user_id is None: return None try: @@ -876,18 +889,19 @@ class Portal: except KeyError: return None - async def _handle_matrix_text(self, sender_id: int, event_id: str, space: int, - client: 'MautrixTelegramClient', message: Dict, reply_to: int - ) -> None: + async def _handle_matrix_text(self, sender_id: TelegramID, event_id: MatrixEventID, + space: TelegramID, client: 'MautrixTelegramClient', message: Dict, + reply_to: TelegramID) -> None: lock = self.require_send_lock(sender_id) async with lock: response = await client.send_message(self.peer, message, reply_to=reply_to, parse_mode=self._matrix_event_to_entities) self._add_telegram_message_to_db(event_id, space, response) - async def _handle_matrix_file(self, msgtype: str, sender_id: int, event_id: str, space: int, - client: 'MautrixTelegramClient', message: dict, reply_to: int - ) -> None: + async def _handle_matrix_file(self, msgtype: str, sender_id: TelegramID, + event_id: MatrixEventID, space: TelegramID, + client: 'MautrixTelegramClient', message: dict, + reply_to: TelegramID) -> None: file = await self.main_intent.download_file(message["url"]) info = message.get("info", {}) @@ -919,9 +933,9 @@ class Portal: caption=caption) self._add_telegram_message_to_db(event_id, space, response) - async def _handle_matrix_location(self, sender_id: int, event_id: str, space: int, - client: 'MautrixTelegramClient', message: Dict, - reply_to: int) -> None: + async def _handle_matrix_location(self, sender_id: TelegramID, event_id: MatrixEventID, + space: TelegramID, client: 'MautrixTelegramClient', + message: Dict[str, Any], reply_to: TelegramID) -> None: try: lat, long = message["geo_uri"][len("geo:"):].split(",") lat, long = float(lat), float(long) @@ -937,7 +951,7 @@ class Portal: caption=caption, entities=entities) self._add_telegram_message_to_db(event_id, space, response) - def _add_telegram_message_to_db(self, event_id: str, space: int, + def _add_telegram_message_to_db(self, event_id: MatrixEventID, space: TelegramID, response: TypeMessage) -> None: self.log.debug("Handled Matrix message: %s", response) self.is_duplicate(response, (event_id, space)) @@ -948,7 +962,8 @@ class Portal: mxid=event_id)) self.db.commit() - async def handle_matrix_message(self, sender: 'u.User', message: dict, event_id: str) -> None: + async def handle_matrix_message(self, sender: 'u.User', message: Dict[str, Any], + event_id: MatrixEventID) -> None: puppet = p.Puppet.get_by_custom_mxid(sender.mxid) if puppet and message.get("net.maunium.telegram.puppet", False): self.log.debug("Ignoring puppet-sent message by confirmed puppet user %s", sender.mxid) @@ -965,7 +980,13 @@ class Portal: await self._pre_process_matrix_message(sender, not logged_in, message) msgtype = message["msgtype"] - if msgtype == "m.text" or (self.bridge_notices and msgtype == "m.notice"): + if msgtype == "m.notice": + bridge_notices = self.get_config("bridge_notices") + if not bridge_notices.get("default", False): + if sender_id not in bridge_notices.get("exceptions"): + return + + if msgtype == "m.text": await self._handle_matrix_text(sender_id, event_id, space, client, message, reply_to) elif msgtype == "m.location": await self._handle_matrix_location(sender_id, event_id, space, client, message, @@ -976,7 +997,8 @@ class Portal: else: self.log.debug(f"Unhandled Matrix event: {message}") - async def handle_matrix_pin(self, sender: 'u.User', pinned_message: Optional[str]) -> None: + async def handle_matrix_pin(self, sender: 'u.User', + pinned_message: Optional[MatrixEventID]) -> None: if self.peer_type != "channel": return try: @@ -1093,7 +1115,7 @@ class Portal: # endregion # region Telegram chat info updating - async def _get_telegram_users_in_matrix_room(self) -> List[int]: + async def _get_telegram_users_in_matrix_room(self) -> List[TelegramID]: user_tgids = set() user_mxids = await self.main_intent.get_room_members(self.mxid, ("join", "invite")) for user_str in user_mxids: @@ -1202,7 +1224,8 @@ class Portal: largest_size.location) if not file: return None - if config["bridge.inline_images"] and (evt.message or evt.fwd_from or evt.reply_to_msg_id): + if self.get_config("inline_images") and (evt.message + or evt.fwd_from or evt.reply_to_msg_id): text, html, relates_to = await formatter.telegram_to_matrix( evt, source, self.main_intent, prefix_html=f"Inline Telegram photo
", @@ -1305,7 +1328,7 @@ class Portal: "external_url": self.get_external_url(evt) } - if attrs["is_sticker"] and config["bridge.native_stickers"]: + if attrs["is_sticker"] and self.get_config("native_stickers"): return await intent.send_sticker(**kwargs) mime_type = info["mimetype"] @@ -1352,7 +1375,7 @@ class Portal: self.log.debug(f"Sending {evt.message} to {self.mxid} by {intent.mxid}") text, html, relates_to = await formatter.telegram_to_matrix(evt, source, self.main_intent) await intent.set_typing(self.mxid, is_typing=False) - msgtype = "m.notice" if is_bot and config["bridge.bot_messages_as_notices"] else "m.text" + msgtype = "m.notice" if is_bot and self.get_config("bot_messages_as_notices") else "m.text" return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to, msgtype=msgtype, timestamp=evt.date, external_url=self.get_external_url(evt)) @@ -1361,7 +1384,7 @@ class Portal: evt: Message) -> None: if not self.mxid: return - elif not config["bridge.edits_as_replies"]: + elif not self.get_config("edits_as_replies"): self.log.debug("Edits as replies disabled, ignoring edit event...") return @@ -1372,7 +1395,8 @@ class Portal: tg_space = self.tgid if self.peer_type == "channel" else source.tgid - temporary_identifier = f"${random.randint(1000000000000,9999999999999)}TGBRIDGEDITEMP" + temporary_identifier = MatrixEventID( + f"${random.randint(1000000000000,9999999999999)}TGBRIDGEDITEMP") duplicate_found = self.is_duplicate(evt, (temporary_identifier, tg_space), force_hash=True) if duplicate_found: mxid, other_tg_space = duplicate_found @@ -1419,7 +1443,8 @@ class Portal: tg_space = self.tgid if self.peer_type == "channel" else source.tgid - temporary_identifier = f"${random.randint(1000000000000,9999999999999)}TGBRIDGETEMP" + temporary_identifier = MatrixEventID( + f"${random.randint(1000000000000,9999999999999)}TGBRIDGETEMP") duplicate_found = self.is_duplicate(evt, (temporary_identifier, tg_space)) if duplicate_found: mxid, other_tg_space = duplicate_found @@ -1681,7 +1706,8 @@ class Portal: def new_db_instance(self) -> DBPortal: return DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type, mxid=self.mxid, username=self.username, megagroup=self.megagroup, - title=self.title, about=self.about, photo_id=self.photo_id) + title=self.title, about=self.about, photo_id=self.photo_id, + config=json.dumps(self.local_config)) def migrate_and_save(self, new_id: TelegramID) -> None: existing = DBPortal.query.get(self.tgid_full) @@ -1702,6 +1728,7 @@ class Portal: self.db_instance.title = self.title self.db_instance.about = self.about self.db_instance.photo_id = self.photo_id + self.db_instance.config = json.dumps(self.local_config) self.db.commit() def delete(self) -> None: @@ -1724,7 +1751,7 @@ class Portal: peer_type=db_portal.peer_type, mxid=db_portal.mxid, username=db_portal.username, megagroup=db_portal.megagroup, title=db_portal.title, about=db_portal.about, photo_id=db_portal.photo_id, - db_instance=db_portal) + config=db_portal.config, db_instance=db_portal) # endregion # region Class instance lookup @@ -1822,7 +1849,9 @@ class Portal: def init(context: Context) -> None: global config Portal.az, Portal.db, config, Portal.loop, Portal.bot = context.core - Portal.bridge_notices = config["bridge.bridge_notices"] + Portal.max_initial_member_sync = config["bridge.max_initial_member_sync"] + Portal.sync_channel_members = config["bridge.sync_channel_members"] + Portal.public_portals = config["bridge.public_portals"] Portal.filter_mode = config["bridge.filter.mode"] Portal.filter_list = config["bridge.filter.list"] Portal.dedup_pre_db_check = config["bridge.deduplication.pre_db_check"] From 9d2d34a25c083068cc24bdea008bb453005c92bf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 24 Sep 2018 17:44:00 +0300 Subject: [PATCH 2/5] Add command to update room-specific config --- example-config.yaml | 4 +- mautrix_telegram/commands/portal.py | 79 ++++++++++++++++++++++++- mautrix_telegram/config.py | 31 ++++++---- mautrix_telegram/portal.py | 7 ++- mautrix_telegram/util/__init__.py | 1 + mautrix_telegram/util/recursive_dict.py | 53 +++++++++++++++++ 6 files changed, 158 insertions(+), 17 deletions(-) create mode 100644 mautrix_telegram/util/recursive_dict.py diff --git a/example-config.yaml b/example-config.yaml index b57da198..209d57a3 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -114,6 +114,8 @@ 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 + # 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. public_portals: true # Whether or not to fetch and handle Telegram updates at startup from the time the bridge was down. @@ -136,8 +138,6 @@ bridge: # 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 - # Highlight changed/added parts in edits. Requires lxml. - highlight_edits: false bridge_notices: # Whether or not Matrix bot messages (type m.notice) should be bridged. default: false diff --git a/mautrix_telegram/commands/portal.py b/mautrix_telegram/commands/portal.py index 9d8b5445..5704f77c 100644 --- a/mautrix_telegram/commands/portal.py +++ b/mautrix_telegram/commands/portal.py @@ -15,6 +15,7 @@ # 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 +from io import StringIO import asyncio from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError, @@ -23,7 +24,8 @@ from telethon.tl.types import ChatForbidden, ChannelForbidden from mautrix_appservice import MatrixRequestError, IntentAPI from ..types import MatrixRoomID, TelegramID -from .. import portal as po, user as u +from ..config import yaml +from .. import portal as po, user as u, util from . import (command_handler, CommandEvent, SECTION_ADMIN, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT) @@ -391,6 +393,81 @@ async def upgrade(evt: CommandEvent) -> Dict: 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) -> Dict: + cmd = evt.args[0].lower() if len(evt.args) > 0 else "help" + portal = po.Portal.get_by_mxid(evt.room_id) + if not portal and cmd != "help": + return await evt.reply("This is not a portal room.") + if cmd == "help": + return await evt.reply("""`$cmdprefix config`: +* `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. +""") + elif cmd == "view": + stream = StringIO() + yaml.dump(portal.local_config, stream) + return await evt.reply(f"Room-specific config:\n```yaml\n{stream.getvalue()}\n```") + elif cmd == "defaults": + stream = StringIO() + yaml.dump({ + "edits_as_replies": evt.config["bridge.edits_as_replies"], + "bridge_notices": evt.config["bridge.bridge_notices"], + "bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"], + "inline_images": evt.config["bridge.inline_images"], + "native_stickers": evt.config["native_stickers"], + "message_formats": evt.config["message_formats"], + "state_event_formats": evt.config["state_event_formats"], + }, stream) + return await evt.reply(f"Bridge instance wide config:\n```yaml\n{stream.getvalue()}\n```") + + key = evt.args[1] if len(evt.args) > 1 else None + value = yaml.load(evt.args[2:]) if len(evt.args) > 2 else None + if cmd == "set": + if not key or not value: + return await evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") + elif util.recursive_set(portal.local_config, key, value): + return await evt.reply(f"Successfully set the value of `{key}` to `{value}`.") + else: + return await evt.reply(f"Failed to set value of `{key}`. " + "Does the path contain non-map types?") + elif cmd == "unset": + if not key: + return await evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") + elif util.recursive_del(portal.local_config, key): + return await evt.reply(f"Successfully deleted `{key}` from config.") + else: + return await evt.reply(f"`{key}` not found in config.") + elif cmd == "add" or cmd == "del": + if not key or not value: + return await evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") + + arr = util.recursive_get(portal.local_config, key) + if not arr: + return await evt.reply(f"`{key}` not found in config. " + f"Maybe do `$cmdprefix+sp config set {key} []` first?") + elif not isinstance(arr, list): + return await evt.reply("`{key}` does not seem to be an array.") + elif cmd == "add": + if value in arr: + return await evt.reply(f"The array at `{key}` already contains `{value}`.") + arr.append(value) + return await evt.reply(f"Successfully added `{value}` to the array at `{key}`") + else: + if value not in arr: + return await evt.reply(f"The array at `{key}` does not contain `{value}`.") + arr.remove(value) + return await 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. " diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py index 8e374a38..d4d946eb 100644 --- a/mautrix_telegram/config.py +++ b/mautrix_telegram/config.py @@ -20,7 +20,7 @@ from ruamel.yaml.comments import CommentedMap import random import string -yaml = YAML() +yaml = YAML() # type: YAML yaml.indent(4) @@ -28,9 +28,20 @@ class DictWithRecursion: def __init__(self, data: Optional[CommentedMap] = None) -> None: self._data = data or CommentedMap() # type: CommentedMap + @staticmethod + def _parse_key(key: str) -> Tuple[str, Optional[str]]: + if '.' not in key: + return key, None + key, next_key = key.split('.', 1) + if len(key) > 0 and key[0] == "[": + end_index = next_key.index("]") + key = key[1:] + "." + next_key[:end_index] + next_key = next_key[end_index + 2:] if len(next_key) > end_index + 1 else None + return key, next_key + def _recursive_get(self, data: CommentedMap, key: str, default_value: Any) -> Any: - if '.' in key: - key, next_key = key.split('.', 1) + key, next_key = self._parse_key(key) + if next_key is not None: next_data = data.get(key, CommentedMap()) return self._recursive_get(next_data, next_key, default_value) return data.get(key, default_value) @@ -47,13 +58,12 @@ class DictWithRecursion: return self[key] is not None def _recursive_set(self, data: CommentedMap, key: str, value: Any) -> None: - if '.' in key: - key, next_key = key.split('.', 1) + key, next_key = self._parse_key(key) + if next_key is not None: if key not in data: data[key] = CommentedMap() next_data = data.get(key, CommentedMap()) - self._recursive_set(next_data, next_key, value) - return + return self._recursive_set(next_data, next_key, value) data[key] = value def set(self, key: str, value: Any, allow_recursion: bool = True) -> None: @@ -66,13 +76,12 @@ class DictWithRecursion: self.set(key, value) def _recursive_del(self, data: CommentedMap, key: str) -> None: - if '.' in key: - key, next_key = key.split('.', 1) + key, next_key = self._parse_key(key) + if next_key is not None: if key not in data: return next_data = data[key] - self._recursive_del(next_data, next_key) - return + return self._recursive_del(next_data, next_key) try: del data[key] del data.ca.items[key] diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index b6cab857..b7303316 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -719,14 +719,14 @@ class Portal: return "" def get_config(self, key: str) -> Any: - local = self.local_config.get("state_event_formats", None) + local = util.recursive_get(self.local_config, key) if local is not None: return local return config[f"bridge.{key}"] async def _get_state_change_message(self, event: str, user: 'u.User', arguments: Optional[Dict] = None) -> Optional[Dict]: - tpl = self.get_config("state_event_formats").get(event, "") + tpl = self.get_config(f"state_event_formats.{event}") if len(tpl) == 0: # Empty format means they don't want the message return None @@ -843,7 +843,8 @@ class Portal: message["formatted_body"] = escape_html(message.get("body", "")) body = message["formatted_body"] - tpl = config.get("message_formats", {}).get(msgtype, "$sender_displayname: $message") + tpl = (self.get_config(f"message_formats.[{msgtype}]") + or "$sender_displayname: $message") displayname = await self.get_displayname(sender) tpl_args = dict(sender_mxid=sender.mxid, sender_username=sender.mxid_localpart, diff --git a/mautrix_telegram/util/__init__.py b/mautrix_telegram/util/__init__.py index 99cdee2a..7071b2d6 100644 --- a/mautrix_telegram/util/__init__.py +++ b/mautrix_telegram/util/__init__.py @@ -1,3 +1,4 @@ from .file_transfer import transfer_file_to_matrix, convert_image from .format_duration import format_duration from .signed_token import sign_token, verify_token +from .recursive_dict import recursive_del, recursive_set, recursive_get diff --git a/mautrix_telegram/util/recursive_dict.py b/mautrix_telegram/util/recursive_dict.py new file mode 100644 index 00000000..fc9284b7 --- /dev/null +++ b/mautrix_telegram/util/recursive_dict.py @@ -0,0 +1,53 @@ +# -*- 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, Any +from ..config import DictWithRecursion + +def recursive_set(data: Dict[str, Any], key: str, value: Any) -> bool: + key, next_key = DictWithRecursion._parse_key(key) + if next_key is not None: + if key not in data: + data[key] = {} + next_data = data.get(key, {}) + if not isinstance(next_data, dict): + return False + return recursive_set(next_data, next_key, value) + data[key] = value + return True + + +def recursive_get(data: Dict[str, Any], key: str) -> Any: + key, next_key = DictWithRecursion._parse_key(key) + if next_key is not None: + next_data = data.get(key, None) + if not next_data: + return None + return recursive_get(next_data, next_key) + return data.get(key, None) + + +def recursive_del(data: Dict[str, any], key: str) -> bool: + key, next_key = DictWithRecursion._parse_key(key) + if next_key is not None: + if key not in data: + return False + next_data = data.get(key, {}) + recursive_del(next_data, next_key) + if key in data: + del data[key] + return True + return False From f758884c753f87920ba9cffe7d687c1a7ed561f8 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 24 Sep 2018 23:41:18 +0300 Subject: [PATCH 3/5] Fix example config and add alembic migration --- ...b54929c22c86_add_portal_specific_config.py | 25 +++++++++++++++++++ example-config.yaml | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/b54929c22c86_add_portal_specific_config.py diff --git a/alembic/versions/b54929c22c86_add_portal_specific_config.py b/alembic/versions/b54929c22c86_add_portal_specific_config.py new file mode 100644 index 00000000..b5d1a420 --- /dev/null +++ b/alembic/versions/b54929c22c86_add_portal_specific_config.py @@ -0,0 +1,25 @@ +"""Add portal-specific config + +Revision ID: b54929c22c86 +Revises: d5f7b8b4b456 +Create Date: 2018-09-24 23:40:33.528710 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "b54929c22c86" +down_revision = "d5f7b8b4b456" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("portal", sa.Column("config", sa.Text(), nullable=True)) + + +def downgrade(): + with op.batch_alter_table("portal") as batch_op: + batch_op.drop_column("config") diff --git a/example-config.yaml b/example-config.yaml index 209d57a3..71b91ee0 100644 --- a/example-config.yaml +++ b/example-config.yaml @@ -145,7 +145,7 @@ bridge: # 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 + - "@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 m.image to make rich captions possible. From 5ccd1bc2fe4549656ca30c73e50da14639896911 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 25 Sep 2018 00:26:02 +0300 Subject: [PATCH 4/5] Fix bugs and switch to commonmark for command replies --- mautrix_telegram/commands/handler.py | 5 +-- mautrix_telegram/commands/portal.py | 44 ++++++++++++++----------- mautrix_telegram/util/recursive_dict.py | 3 +- requirements.txt | 2 +- setup.py | 2 +- 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py index e8e54c1f..5be4d4fd 100644 --- a/mautrix_telegram/commands/handler.py +++ b/mautrix_telegram/commands/handler.py @@ -15,7 +15,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . from typing import Awaitable, Callable, Dict, List, NamedTuple, Optional -import markdown +import commonmark import logging from telethon.errors import FloodWaitError @@ -60,7 +60,8 @@ class CommandEvent: message = message.replace("$cmdprefix", self.command_prefix) html = None if render_markdown: - html = markdown.markdown(message, safe_mode="escape" if allow_html else False) + html = commonmark.commonmark(message if allow_html else + message.replace("<", "<").replace(">", ">")) elif allow_html: html = message return self.az.intent.send_notice(self.room_id, message, html=html) diff --git a/mautrix_telegram/commands/portal.py b/mautrix_telegram/commands/portal.py index 5704f77c..43cdca76 100644 --- a/mautrix_telegram/commands/portal.py +++ b/mautrix_telegram/commands/portal.py @@ -393,7 +393,6 @@ async def upgrade(evt: CommandEvent) -> Dict: 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_> [...]") @@ -402,37 +401,43 @@ async def config(evt: CommandEvent) -> Dict: portal = po.Portal.get_by_mxid(evt.room_id) if not portal and cmd != "help": return await evt.reply("This is not a portal room.") - if cmd == "help": - return await evt.reply("""`$cmdprefix config`: -* `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. + if cmd not in ("view", "defaults", "set", "unset", "add", "del"): + return await 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. """) elif cmd == "view": stream = StringIO() yaml.dump(portal.local_config, stream) - return await evt.reply(f"Room-specific config:\n```yaml\n{stream.getvalue()}\n```") + return await evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}\n```", + allow_html=True) elif cmd == "defaults": stream = StringIO() yaml.dump({ "edits_as_replies": evt.config["bridge.edits_as_replies"], - "bridge_notices": evt.config["bridge.bridge_notices"], + "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["native_stickers"], - "message_formats": evt.config["message_formats"], - "state_event_formats": evt.config["state_event_formats"], + "native_stickers": evt.config["bridge.native_stickers"], + "message_formats": evt.config["bridge.message_formats"], + "state_event_formats": evt.config["bridge.state_event_formats"], }, stream) - return await evt.reply(f"Bridge instance wide config:\n```yaml\n{stream.getvalue()}\n```") + return await evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```", + allow_html=True) key = evt.args[1] if len(evt.args) > 1 else None - value = yaml.load(evt.args[2:]) if len(evt.args) > 2 else None + value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 else None if cmd == "set": - if not key or not value: + if not key or value is None: return await evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") elif util.recursive_set(portal.local_config, key, value): return await evt.reply(f"Successfully set the value of `{key}` to `{value}`.") @@ -447,7 +452,7 @@ async def config(evt: CommandEvent) -> Dict: else: return await evt.reply(f"`{key}` not found in config.") elif cmd == "add" or cmd == "del": - if not key or not value: + if not key or value is None: return await evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") arr = util.recursive_get(portal.local_config, key) @@ -466,6 +471,7 @@ async def config(evt: CommandEvent) -> Dict: return await evt.reply(f"The array at `{key}` does not contain `{value}`.") arr.remove(value) return await evt.reply(f"Successfully removed `{value}` from the array at `{key}`") + portal.save() @command_handler(help_section=SECTION_PORTAL_MANAGEMENT, diff --git a/mautrix_telegram/util/recursive_dict.py b/mautrix_telegram/util/recursive_dict.py index fc9284b7..6f21a638 100644 --- a/mautrix_telegram/util/recursive_dict.py +++ b/mautrix_telegram/util/recursive_dict.py @@ -17,6 +17,7 @@ from typing import Dict, Any from ..config import DictWithRecursion + def recursive_set(data: Dict[str, Any], key: str, value: Any) -> bool: key, next_key = DictWithRecursion._parse_key(key) if next_key is not None: @@ -46,7 +47,7 @@ def recursive_del(data: Dict[str, any], key: str) -> bool: if key not in data: return False next_data = data.get(key, {}) - recursive_del(next_data, next_key) + return recursive_del(next_data, next_key) if key in data: del data[key] return True diff --git a/requirements.txt b/requirements.txt index 2fcca4c7..e9e5ccc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ ruamel.yaml python-magic SQLAlchemy alembic -Markdown +commonmark future-fstrings telethon telethon-session-sqlalchemy diff --git a/setup.py b/setup.py index 9d35da5d..aa2aa490 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,7 @@ setuptools.setup( "mautrix-appservice>=0.3.6,<0.4.0", "SQLAlchemy>=1.2.3,<2", "alembic>=1.0.0,<2", - "Markdown>=2.6.11,<3", + "commonmark>=0.8.1,<1", "ruamel.yaml>=0.15.35,<0.16", "future-fstrings>=0.4.2", "python-magic>=0.4.15,<0.5", From 78e480477417b99a4452d7ebbc9fab284774ab27 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 25 Sep 2018 00:47:16 +0300 Subject: [PATCH 5/5] Fix minor things and improve code style --- mautrix_telegram/commands/portal.py | 160 +++++++++++++++++----------- mautrix_telegram/portal.py | 6 +- 2 files changed, 99 insertions(+), 67 deletions(-) diff --git a/mautrix_telegram/commands/portal.py b/mautrix_telegram/commands/portal.py index 43cdca76..ede472e3 100644 --- a/mautrix_telegram/commands/portal.py +++ b/mautrix_telegram/commands/portal.py @@ -14,7 +14,7 @@ # # 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 +from typing import Dict, Callable, Optional, Tuple, Coroutine, Awaitable from io import StringIO import asyncio @@ -396,13 +396,38 @@ async def upgrade(evt: CommandEvent) -> Dict: @command_handler(help_section=SECTION_PORTAL_MANAGEMENT, help_text="View or change per-portal settings.", help_args="<`help`|_subcommand_> [...]") -async def config(evt: CommandEvent) -> Dict: +async def config(evt: CommandEvent) -> None: cmd = evt.args[0].lower() if len(evt.args) > 0 else "help" - portal = po.Portal.get_by_mxid(evt.room_id) - if not portal and cmd != "help": - return await evt.reply("This is not a portal room.") if cmd not in ("view", "defaults", "set", "unset", "add", "del"): - return await evt.reply("""**Usage:** `$cmdprefix config [...]`. Subcommands: + 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. @@ -412,66 +437,73 @@ async def config(evt: CommandEvent) -> Dict: * **add** <_key_> <_value_> - Add a value to an array. * **del** <_key_> <_value_> - Remove a value from an array. """) - elif cmd == "view": - stream = StringIO() - yaml.dump(portal.local_config, stream) - return await evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}\n```", - allow_html=True) - elif cmd == "defaults": - 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"], - }, stream) - return await evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```", - allow_html=True) - 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": - if not key or value is None: - return await evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") - elif util.recursive_set(portal.local_config, key, value): - return await evt.reply(f"Successfully set the value of `{key}` to `{value}`.") - else: - return await evt.reply(f"Failed to set value of `{key}`. " - "Does the path contain non-map types?") - elif cmd == "unset": - if not key: - return await evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") - elif util.recursive_del(portal.local_config, key): - return await evt.reply(f"Successfully deleted `{key}` from config.") - else: - return await evt.reply(f"`{key}` not found in config.") - elif cmd == "add" or cmd == "del": - if not key or value is None: - return await evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") - arr = util.recursive_get(portal.local_config, key) - if not arr: - return await evt.reply(f"`{key}` not found in config. " - f"Maybe do `$cmdprefix+sp config set {key} []` first?") - elif not isinstance(arr, list): - return await evt.reply("`{key}` does not seem to be an array.") - elif cmd == "add": - if value in arr: - return await evt.reply(f"The array at `{key}` already contains `{value}`.") - arr.append(value) - return await evt.reply(f"Successfully added `{value}` to the array at `{key}`") - else: - if value not in arr: - return await evt.reply(f"The array at `{key}` does not contain `{value}`.") - arr.remove(value) - return await evt.reply(f"Successfully removed `{value}` from the array at `{key}`") - portal.save() +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()}```", + allow_html=True) + + +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"], + }, stream) + return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```", + allow_html=True) + + +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, diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py index b7303316..789104f2 100644 --- a/mautrix_telegram/portal.py +++ b/mautrix_telegram/portal.py @@ -983,9 +983,9 @@ class Portal: if msgtype == "m.notice": bridge_notices = self.get_config("bridge_notices") - if not bridge_notices.get("default", False): - if sender_id not in bridge_notices.get("exceptions"): - return + if not bridge_notices.get("default", False) and sender_id not in bridge_notices.get( + "exceptions"): + return if msgtype == "m.text": await self._handle_matrix_text(sender_id, event_id, space, client, message, reply_to)