Merge pull request #231 from tulir/room-specific-settings

Add room specific config
This commit is contained in:
Tulir Asokan
2018-09-25 00:47:44 +03:00
committed by GitHub
13 changed files with 334 additions and 83 deletions
@@ -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")
+23 -16
View File
@@ -95,15 +95,6 @@ bridge:
- username - username
- phone number - 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 # 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 # synced when they send messages. The maximum is 10000, after which the Telegram server
# will not send any more members. # will not send any more members.
@@ -119,19 +110,14 @@ bridge:
# Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix # 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) # login website (see appservice.public config section)
allow_matrix_login: true 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. # Whether or not to bridge plaintext highlights.
# Only enable this if your displayname_template has some static part that the bridge can use to # Only enable this if your displayname_template has some static part that the bridge can use to
# reliably identify what is a plaintext highlight. # reliably identify what is a plaintext highlight.
plaintext_highlights: false 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. # Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
public_portals: true 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. # 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. # Currently only works for private chats and normal groups.
catch_up: false catch_up: false
@@ -149,6 +135,27 @@ bridge:
# You might need to increase this on high-traffic bridge instances. # You might need to increase this on high-traffic bridge instances.
cache_queue_length: 20 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 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. # 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. # Telegram doesn't have built-in emotes, so the m.emote format is also used for non-relaybot users.
+2 -2
View File
@@ -35,7 +35,7 @@ from alchemysession import AlchemySessionContainer
from . import portal as po, puppet as pu, __version__ from . import portal as po, puppet as pu, __version__
from .db import Message as DBMessage from .db import Message as DBMessage
from .types import TelegramID from .types import TelegramID, MatrixUserID
from .tgclient import MautrixTelegramClient from .tgclient import MautrixTelegramClient
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -69,7 +69,7 @@ class AbstractUser(ABC):
self.relaybot_whitelisted = False # type: bool self.relaybot_whitelisted = False # type: bool
self.client = None # type: MautrixTelegramClient self.client = None # type: MautrixTelegramClient
self.tgid = None # type: TelegramID self.tgid = None # type: TelegramID
self.mxid = None # type: str self.mxid = None # type: MatrixUserID
self.is_relaybot = False # type: bool self.is_relaybot = False # type: bool
self.is_bot = False # type: bool self.is_bot = False # type: bool
self.relaybot = None # type: Optional[Bot] self.relaybot = None # type: Optional[Bot]
+3 -2
View File
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, Callable, Dict, List, NamedTuple, Optional from typing import Awaitable, Callable, Dict, List, NamedTuple, Optional
import markdown import commonmark
import logging import logging
from telethon.errors import FloodWaitError from telethon.errors import FloodWaitError
@@ -60,7 +60,8 @@ class CommandEvent:
message = message.replace("$cmdprefix", self.command_prefix) message = message.replace("$cmdprefix", self.command_prefix)
html = None html = None
if render_markdown: 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("<", "&lt;").replace(">", "&gt;"))
elif allow_html: elif allow_html:
html = message html = message
return self.az.intent.send_notice(self.room_id, message, html=html) return self.az.intent.send_notice(self.room_id, message, html=html)
+117 -2
View File
@@ -14,7 +14,8 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Callable, Optional, Tuple, Coroutine from typing import Dict, Callable, Optional, Tuple, Coroutine, Awaitable
from io import StringIO
import asyncio import asyncio
from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError, from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError,
@@ -23,7 +24,8 @@ from telethon.tl.types import ChatForbidden, ChannelForbidden
from mautrix_appservice import MatrixRequestError, IntentAPI from mautrix_appservice import MatrixRequestError, IntentAPI
from ..types import MatrixRoomID, TelegramID 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, from . import (command_handler, CommandEvent,
SECTION_ADMIN, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT) SECTION_ADMIN, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT)
@@ -391,6 +393,119 @@ async def upgrade(evt: CommandEvent) -> Dict:
return await evt.reply(e.args[0]) 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 <subcommand> [...]`. 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()}```",
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 <key> <value>`")
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 <key>`")
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} <key> <value>`")
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, @command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_args="<_name_|`-`>", help_args="<_name_|`-`>",
help_text="Change the username of a supergroup/channel. " help_text="Change the username of a supergroup/channel. "
+27 -12
View File
@@ -20,7 +20,7 @@ from ruamel.yaml.comments import CommentedMap
import random import random
import string import string
yaml = YAML() yaml = YAML() # type: YAML
yaml.indent(4) yaml.indent(4)
@@ -28,9 +28,20 @@ class DictWithRecursion:
def __init__(self, data: Optional[CommentedMap] = None) -> None: def __init__(self, data: Optional[CommentedMap] = None) -> None:
self._data = data or CommentedMap() # type: CommentedMap 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: def _recursive_get(self, data: CommentedMap, key: str, default_value: Any) -> Any:
if '.' in key: key, next_key = self._parse_key(key)
key, next_key = key.split('.', 1) if next_key is not None:
next_data = data.get(key, CommentedMap()) next_data = data.get(key, CommentedMap())
return self._recursive_get(next_data, next_key, default_value) return self._recursive_get(next_data, next_key, default_value)
return data.get(key, default_value) return data.get(key, default_value)
@@ -47,13 +58,12 @@ class DictWithRecursion:
return self[key] is not None return self[key] is not None
def _recursive_set(self, data: CommentedMap, key: str, value: Any) -> None: def _recursive_set(self, data: CommentedMap, key: str, value: Any) -> None:
if '.' in key: key, next_key = self._parse_key(key)
key, next_key = key.split('.', 1) if next_key is not None:
if key not in data: if key not in data:
data[key] = CommentedMap() data[key] = CommentedMap()
next_data = data.get(key, CommentedMap()) next_data = data.get(key, CommentedMap())
self._recursive_set(next_data, next_key, value) return self._recursive_set(next_data, next_key, value)
return
data[key] = value data[key] = value
def set(self, key: str, value: Any, allow_recursion: bool = True) -> None: def set(self, key: str, value: Any, allow_recursion: bool = True) -> None:
@@ -66,13 +76,12 @@ class DictWithRecursion:
self.set(key, value) self.set(key, value)
def _recursive_del(self, data: CommentedMap, key: str) -> None: def _recursive_del(self, data: CommentedMap, key: str) -> None:
if '.' in key: key, next_key = self._parse_key(key)
key, next_key = key.split('.', 1) if next_key is not None:
if key not in data: if key not in data:
return return
next_data = data[key] next_data = data[key]
self._recursive_del(next_data, next_key) return self._recursive_del(next_data, next_key)
return
try: try:
del data[key] del data[key]
del data.ca.items[key] del data.ca.items[key]
@@ -183,7 +192,13 @@ class Config(DictWithRecursion):
copy("bridge.edits_as_replies") copy("bridge.edits_as_replies")
copy("bridge.highlight_edits") 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.bot_messages_as_notices")
copy("bridge.max_initial_member_sync") copy("bridge.max_initial_member_sync")
copy("bridge.sync_channel_members") copy("bridge.sync_channel_members")
+2
View File
@@ -39,6 +39,8 @@ class Portal(Base):
# Matrix portal information # Matrix portal information
mxid = Column(String, unique=True, nullable=True) # type: Optional[MatrixRoomID] mxid = Column(String, unique=True, nullable=True) # type: Optional[MatrixRoomID]
config = Column(Text, nullable=True)
# Telegram chat metadata # Telegram chat metadata
username = Column(String, nullable=True) username = Column(String, nullable=True)
title = Column(String, nullable=True) title = Column(String, nullable=True)
@@ -14,7 +14,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
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 re
import logging import logging
@@ -22,6 +22,7 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M
TypeMessageEntity) TypeMessageEntity)
from ... import puppet as pu from ... import puppet as pu
from ...types import TelegramID, MatrixRoomID
from ...db import Message as DBMessage from ...db import Message as DBMessage
from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html, from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text) 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 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 def matrix_reply_to_telegram(content: Dict[str, Any], tg_space: TelegramID,
) -> Optional[int]: room_id: Optional[MatrixRoomID] = None) -> Optional[TelegramID]:
try: try:
reply = content["m.relates_to"]["m.in_reply_to"] reply = content["m.relates_to"]["m.in_reply_to"]
room_id = room_id or reply["room_id"] room_id = room_id or reply["room_id"]
+74 -44
View File
@@ -14,7 +14,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
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 collections import deque
from datetime import datetime from datetime import datetime
from string import Template from string import Template
@@ -25,6 +25,7 @@ import mimetypes
import unicodedata import unicodedata
import hashlib import hashlib
import logging import logging
import json
import re import re
import magic import magic
@@ -79,8 +80,8 @@ config = None # type: Config
TypeMessage = Union[Message, MessageService] TypeMessage = Union[Message, MessageService]
TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant] TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant]
DedupMXID = Tuple[str, int] DedupMXID = Tuple[MatrixEventID, TelegramID]
InviteList = Union[str, List[str]] InviteList = Union[MatrixUserID, List[MatrixUserID]]
class Portal: class Portal:
@@ -90,10 +91,13 @@ class Portal:
bot = None # type: Bot bot = None # type: Bot
loop = None # type: asyncio.AbstractEventLoop loop = None # type: asyncio.AbstractEventLoop
# Config cache
filter_mode = None # type: str filter_mode = None # type: str
filter_list = None # type: List[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_pre_db_check = False # type: bool
dedup_cache_queue_length = 20 # type: int dedup_cache_queue_length = 20 # type: int
@@ -102,6 +106,7 @@ class Portal:
mx_alias_regex = None # type: Pattern mx_alias_regex = None # type: Pattern
hs_domain = None # type: str hs_domain = None # type: str
# Instance cache
by_mxid = {} # type: Dict[MatrixRoomID, Portal] by_mxid = {} # type: Dict[MatrixRoomID, Portal]
by_tgid = {} # type: Dict[Tuple[TelegramID, TelegramID], Portal] by_tgid = {} # type: Dict[Tuple[TelegramID, TelegramID], Portal]
@@ -109,7 +114,7 @@ class Portal:
mxid: Optional[MatrixRoomID] = None, username: Optional[str] = None, mxid: Optional[MatrixRoomID] = None, username: Optional[str] = None,
megagroup: Optional[bool] = False, title: Optional[str] = None, megagroup: Optional[bool] = False, title: Optional[str] = None,
about: Optional[str] = None, photo_id: 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.mxid = mxid # type: Optional[MatrixRoomID]
self.tgid = tgid # type: TelegramID self.tgid = tgid # type: TelegramID
self.tg_receiver = tg_receiver or 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.title = title # type: Optional[str]
self.about = about # type: str self.about = about # type: str
self.photo_id = photo_id # 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._db_instance = db_instance # type: DBPortal
self.deleted = False # type: bool self.deleted = False # type: bool
@@ -248,7 +254,7 @@ class Portal:
try: try:
found_mxid = self._dedup_mxid[evt_hash] found_mxid = self._dedup_mxid[evt_hash]
except KeyError: except KeyError:
return "None", 0 return MatrixEventID("None"), TelegramID(0)
if found_mxid != expected_mxid: if found_mxid != expected_mxid:
return found_mxid return found_mxid
@@ -346,7 +352,7 @@ class Portal:
self.megagroup = entity.megagroup self.megagroup = entity.megagroup
if self.peer_type == "channel" and entity.username: if self.peer_type == "channel" and entity.username:
public = config["bridge.public_portals"] public = Portal.public_portals
alias = self._get_alias_localpart(entity.username) alias = self._get_alias_localpart(entity.username)
self.username = entity.username self.username = entity.username
else: else:
@@ -451,7 +457,7 @@ class Portal:
# * The member sync count is limited, because then we might ignore some members. # * 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. # * It's a channel, because non-admins don't have access to the member list.
trust_member_list = (len(allowed_tgids) < 9900 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")) and (self.megagroup or self.peer_type != "channel"))
if trust_member_list: if trust_member_list:
joined_mxids = cast(List[MatrixUserID], joined_mxids = cast(List[MatrixUserID],
@@ -533,7 +539,7 @@ class Portal:
self.username = username or None self.username = username or None
if self.username: if self.username:
await self.main_intent.add_room_alias(self.mxid, self._get_alias_localpart()) 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") await self.main_intent.set_join_rule(self.mxid, "public")
else: else:
await self.main_intent.set_join_rule(self.mxid, "invite") 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)) chat = await user.client(GetFullChatRequest(chat_id=self.tgid))
return chat.users, chat.full_chat.participants.participants return chat.users, chat.full_chat.participants.participants
elif self.peer_type == "channel": 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 [], [] return [], []
limit = config["bridge.max_initial_member_sync"] limit = Portal.max_initial_member_sync
if limit == 0: if limit == 0:
return [], [] return [], []
@@ -712,9 +718,15 @@ class Portal:
else: else:
return "" return ""
def get_config(self, key: str) -> Any:
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', async def _get_state_change_message(self, event: str, user: 'u.User',
arguments: Optional[Dict] = None) -> Optional[Dict]: arguments: Optional[Dict] = None) -> Optional[Dict]:
tpl = config[f"bridge.state_event_formats.{event}"] tpl = self.get_config(f"state_event_formats.{event}")
if len(tpl) == 0: if len(tpl) == 0:
# Empty format means they don't want the message # Empty format means they don't want the message
return None return None
@@ -731,7 +743,7 @@ class Portal:
} }
async def name_change_matrix(self, user: 'u.User', displayname: str, prev_displayname: str, 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): async with self.require_send_lock(self.bot.tgid):
message = await self._get_state_change_message( message = await self._get_state_change_message(
"name_change", user, "name_change", user,
@@ -805,7 +817,7 @@ class Portal:
channel = await self.get_input_entity(user) channel = await self.get_input_entity(user)
await user.client(LeaveChannelRequest(channel=channel)) 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): if await user.needs_relaybot(self):
async with self.require_send_lock(self.bot.tgid): async with self.require_send_lock(self.bot.tgid):
message = await self._get_state_change_message("join", user) message = await self._get_state_change_message("join", user)
@@ -824,14 +836,15 @@ class Portal:
# We'll just assume the user is already in the chat. # We'll just assume the user is already in the chat.
pass 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: if "formatted_body" not in message:
message["format"] = "org.matrix.custom.html" message["format"] = "org.matrix.custom.html"
message["formatted_body"] = escape_html(message.get("body", "")) message["formatted_body"] = escape_html(message.get("body", ""))
body = message["formatted_body"] body = message["formatted_body"]
tpl = config["bridge.message_formats"].get(msgtype, tpl = (self.get_config(f"message_formats.[{msgtype}]")
"<b>$sender_displayname</b>: $message") or "<b>$sender_displayname</b>: $message")
displayname = await self.get_displayname(sender) displayname = await self.get_displayname(sender)
tpl_args = dict(sender_mxid=sender.mxid, tpl_args = dict(sender_mxid=sender.mxid,
sender_username=sender.mxid_localpart, sender_username=sender.mxid_localpart,
@@ -840,7 +853,7 @@ class Portal:
message["formatted_body"] = Template(tpl).safe_substitute(tpl_args) message["formatted_body"] = Template(tpl).safe_substitute(tpl_args)
async def _pre_process_matrix_message(self, sender: 'u.User', use_relaybot: bool, 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") msgtype = message.get("msgtype", "m.text")
if msgtype == "m.emote": if msgtype == "m.emote":
await self._apply_msg_format(sender, msgtype, message) await self._apply_msg_format(sender, msgtype, message)
@@ -849,7 +862,8 @@ class Portal:
await self._apply_msg_format(sender, msgtype, message) await self._apply_msg_format(sender, msgtype, message)
@staticmethod @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: try:
if event.get("format", None) == "org.matrix.custom.html": if event.get("format", None) == "org.matrix.custom.html":
message, entities = formatter.matrix_to_telegram(event.get("formatted_body", "")) message, entities = formatter.matrix_to_telegram(event.get("formatted_body", ""))
@@ -859,7 +873,7 @@ class Portal:
message, entities = None, None message, entities = None, None
return message, entities 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: if user_id is None:
raise ValueError("Required send lock for none id") raise ValueError("Required send lock for none id")
try: try:
@@ -868,7 +882,7 @@ class Portal:
self._send_locks[user_id] = asyncio.Lock() self._send_locks[user_id] = asyncio.Lock()
return self._send_locks[user_id] 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: if user_id is None:
return None return None
try: try:
@@ -876,18 +890,19 @@ class Portal:
except KeyError: except KeyError:
return None return None
async def _handle_matrix_text(self, sender_id: int, event_id: str, space: int, async def _handle_matrix_text(self, sender_id: TelegramID, event_id: MatrixEventID,
client: 'MautrixTelegramClient', message: Dict, reply_to: int space: TelegramID, client: 'MautrixTelegramClient', message: Dict,
) -> None: reply_to: TelegramID) -> None:
lock = self.require_send_lock(sender_id) lock = self.require_send_lock(sender_id)
async with lock: async with lock:
response = await client.send_message(self.peer, message, reply_to=reply_to, response = await client.send_message(self.peer, message, reply_to=reply_to,
parse_mode=self._matrix_event_to_entities) parse_mode=self._matrix_event_to_entities)
self._add_telegram_message_to_db(event_id, space, response) 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, async def _handle_matrix_file(self, msgtype: str, sender_id: TelegramID,
client: 'MautrixTelegramClient', message: dict, reply_to: int event_id: MatrixEventID, space: TelegramID,
) -> None: client: 'MautrixTelegramClient', message: dict,
reply_to: TelegramID) -> None:
file = await self.main_intent.download_file(message["url"]) file = await self.main_intent.download_file(message["url"])
info = message.get("info", {}) info = message.get("info", {})
@@ -919,9 +934,9 @@ class Portal:
caption=caption) caption=caption)
self._add_telegram_message_to_db(event_id, space, response) self._add_telegram_message_to_db(event_id, space, response)
async def _handle_matrix_location(self, sender_id: int, event_id: str, space: int, async def _handle_matrix_location(self, sender_id: TelegramID, event_id: MatrixEventID,
client: 'MautrixTelegramClient', message: Dict, space: TelegramID, client: 'MautrixTelegramClient',
reply_to: int) -> None: message: Dict[str, Any], reply_to: TelegramID) -> None:
try: try:
lat, long = message["geo_uri"][len("geo:"):].split(",") lat, long = message["geo_uri"][len("geo:"):].split(",")
lat, long = float(lat), float(long) lat, long = float(lat), float(long)
@@ -937,7 +952,7 @@ class Portal:
caption=caption, entities=entities) caption=caption, entities=entities)
self._add_telegram_message_to_db(event_id, space, response) 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: response: TypeMessage) -> None:
self.log.debug("Handled Matrix message: %s", response) self.log.debug("Handled Matrix message: %s", response)
self.is_duplicate(response, (event_id, space)) self.is_duplicate(response, (event_id, space))
@@ -948,7 +963,8 @@ class Portal:
mxid=event_id)) mxid=event_id))
self.db.commit() 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) puppet = p.Puppet.get_by_custom_mxid(sender.mxid)
if puppet and message.get("net.maunium.telegram.puppet", False): if puppet and message.get("net.maunium.telegram.puppet", False):
self.log.debug("Ignoring puppet-sent message by confirmed puppet user %s", sender.mxid) self.log.debug("Ignoring puppet-sent message by confirmed puppet user %s", sender.mxid)
@@ -965,7 +981,13 @@ class Portal:
await self._pre_process_matrix_message(sender, not logged_in, message) await self._pre_process_matrix_message(sender, not logged_in, message)
msgtype = message["msgtype"] 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) 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) await self._handle_matrix_text(sender_id, event_id, space, client, message, reply_to)
elif msgtype == "m.location": elif msgtype == "m.location":
await self._handle_matrix_location(sender_id, event_id, space, client, message, await self._handle_matrix_location(sender_id, event_id, space, client, message,
@@ -976,7 +998,8 @@ class Portal:
else: else:
self.log.debug(f"Unhandled Matrix event: {message}") 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": if self.peer_type != "channel":
return return
try: try:
@@ -1093,7 +1116,7 @@ class Portal:
# endregion # endregion
# region Telegram chat info updating # 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_tgids = set()
user_mxids = await self.main_intent.get_room_members(self.mxid, ("join", "invite")) user_mxids = await self.main_intent.get_room_members(self.mxid, ("join", "invite"))
for user_str in user_mxids: for user_str in user_mxids:
@@ -1202,7 +1225,8 @@ class Portal:
largest_size.location) largest_size.location)
if not file: if not file:
return None 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( text, html, relates_to = await formatter.telegram_to_matrix(
evt, source, self.main_intent, evt, source, self.main_intent,
prefix_html=f"<img src='{file.mxc}' alt='Inline Telegram photo'/><br/>", prefix_html=f"<img src='{file.mxc}' alt='Inline Telegram photo'/><br/>",
@@ -1305,7 +1329,7 @@ class Portal:
"external_url": self.get_external_url(evt) "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) return await intent.send_sticker(**kwargs)
mime_type = info["mimetype"] mime_type = info["mimetype"]
@@ -1352,7 +1376,7 @@ class Portal:
self.log.debug(f"Sending {evt.message} to {self.mxid} by {intent.mxid}") 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) text, html, relates_to = await formatter.telegram_to_matrix(evt, source, self.main_intent)
await intent.set_typing(self.mxid, is_typing=False) 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, return await intent.send_text(self.mxid, text, html=html, relates_to=relates_to,
msgtype=msgtype, timestamp=evt.date, msgtype=msgtype, timestamp=evt.date,
external_url=self.get_external_url(evt)) external_url=self.get_external_url(evt))
@@ -1361,7 +1385,7 @@ class Portal:
evt: Message) -> None: evt: Message) -> None:
if not self.mxid: if not self.mxid:
return 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...") self.log.debug("Edits as replies disabled, ignoring edit event...")
return return
@@ -1372,7 +1396,8 @@ class Portal:
tg_space = self.tgid if self.peer_type == "channel" else source.tgid 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) duplicate_found = self.is_duplicate(evt, (temporary_identifier, tg_space), force_hash=True)
if duplicate_found: if duplicate_found:
mxid, other_tg_space = duplicate_found mxid, other_tg_space = duplicate_found
@@ -1419,7 +1444,8 @@ class Portal:
tg_space = self.tgid if self.peer_type == "channel" else source.tgid 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)) duplicate_found = self.is_duplicate(evt, (temporary_identifier, tg_space))
if duplicate_found: if duplicate_found:
mxid, other_tg_space = duplicate_found mxid, other_tg_space = duplicate_found
@@ -1681,7 +1707,8 @@ class Portal:
def new_db_instance(self) -> DBPortal: def new_db_instance(self) -> DBPortal:
return DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type, return DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type,
mxid=self.mxid, username=self.username, megagroup=self.megagroup, 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: def migrate_and_save(self, new_id: TelegramID) -> None:
existing = DBPortal.query.get(self.tgid_full) existing = DBPortal.query.get(self.tgid_full)
@@ -1702,6 +1729,7 @@ class Portal:
self.db_instance.title = self.title self.db_instance.title = self.title
self.db_instance.about = self.about self.db_instance.about = self.about
self.db_instance.photo_id = self.photo_id self.db_instance.photo_id = self.photo_id
self.db_instance.config = json.dumps(self.local_config)
self.db.commit() self.db.commit()
def delete(self) -> None: def delete(self) -> None:
@@ -1724,7 +1752,7 @@ class Portal:
peer_type=db_portal.peer_type, mxid=db_portal.mxid, peer_type=db_portal.peer_type, mxid=db_portal.mxid,
username=db_portal.username, megagroup=db_portal.megagroup, username=db_portal.username, megagroup=db_portal.megagroup,
title=db_portal.title, about=db_portal.about, photo_id=db_portal.photo_id, 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 # endregion
# region Class instance lookup # region Class instance lookup
@@ -1822,7 +1850,9 @@ class Portal:
def init(context: Context) -> None: def init(context: Context) -> None:
global config global config
Portal.az, Portal.db, config, Portal.loop, Portal.bot = context.core 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_mode = config["bridge.filter.mode"]
Portal.filter_list = config["bridge.filter.list"] Portal.filter_list = config["bridge.filter.list"]
Portal.dedup_pre_db_check = config["bridge.deduplication.pre_db_check"] Portal.dedup_pre_db_check = config["bridge.deduplication.pre_db_check"]
+1
View File
@@ -1,3 +1,4 @@
from .file_transfer import transfer_file_to_matrix, convert_image from .file_transfer import transfer_file_to_matrix, convert_image
from .format_duration import format_duration from .format_duration import format_duration
from .signed_token import sign_token, verify_token from .signed_token import sign_token, verify_token
from .recursive_dict import recursive_del, recursive_set, recursive_get
+54
View File
@@ -0,0 +1,54 @@
# -*- 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 <https://www.gnu.org/licenses/>.
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, {})
return recursive_del(next_data, next_key)
if key in data:
del data[key]
return True
return False
+1 -1
View File
@@ -4,7 +4,7 @@ ruamel.yaml
python-magic python-magic
SQLAlchemy SQLAlchemy
alembic alembic
Markdown commonmark
future-fstrings future-fstrings
telethon telethon
telethon-session-sqlalchemy telethon-session-sqlalchemy
+1 -1
View File
@@ -30,7 +30,7 @@ setuptools.setup(
"mautrix-appservice>=0.3.6,<0.4.0", "mautrix-appservice>=0.3.6,<0.4.0",
"SQLAlchemy>=1.2.3,<2", "SQLAlchemy>=1.2.3,<2",
"alembic>=1.0.0,<2", "alembic>=1.0.0,<2",
"Markdown>=2.6.11,<3", "commonmark>=0.8.1,<1",
"ruamel.yaml>=0.15.35,<0.16", "ruamel.yaml>=0.15.35,<0.16",
"future-fstrings>=0.4.2", "future-fstrings>=0.4.2",
"python-magic>=0.4.15,<0.5", "python-magic>=0.4.15,<0.5",