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