├── MANIFEST.in ├── .gitattributes ├── .vscode └── settings.json ├── heisenbridge ├── __init__.py ├── hidden_room.py ├── version.py ├── identd.py ├── websocket.py ├── appservice.py ├── command_parse.py ├── parser.py ├── event_queue.py ├── space_room.py ├── irc.py ├── plumbed_room.py ├── room.py ├── channel_room.py ├── control_room.py └── private_room.py ├── .dockerignore ├── logo ├── heisenbridge-dark.png ├── heisenbridge-light.png ├── heisenbridge-dark-transparent.png ├── heisenbridge-light-transparent.png └── heisenbridge.svg ├── docker-compose ├── init │ ├── heisenbridge-init.sh │ └── synapse-init.sh ├── docker-compose.yml └── README.md ├── pyproject.toml ├── .pre-commit-config.yaml ├── tests ├── test_import.py └── test_pills.py ├── docker-compose.yml ├── Dockerfile ├── setup.cfg ├── LICENSE ├── setup.py ├── .gitignore └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include logo/* 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.formatting.provider": "black" 3 | } 4 | -------------------------------------------------------------------------------- /heisenbridge/__init__.py: -------------------------------------------------------------------------------- 1 | from heisenbridge.version import __version__ # noqa: F401 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !LICENSE 3 | !README.md 4 | !heisenbridge/ 5 | !setup.* 6 | !.git 7 | -------------------------------------------------------------------------------- /logo/heisenbridge-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifi/heisenbridge/HEAD/logo/heisenbridge-dark.png -------------------------------------------------------------------------------- /logo/heisenbridge-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifi/heisenbridge/HEAD/logo/heisenbridge-light.png -------------------------------------------------------------------------------- /logo/heisenbridge-dark-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifi/heisenbridge/HEAD/logo/heisenbridge-dark-transparent.png -------------------------------------------------------------------------------- /logo/heisenbridge-light-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hifi/heisenbridge/HEAD/logo/heisenbridge-light-transparent.png -------------------------------------------------------------------------------- /docker-compose/init/heisenbridge-init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ ! -f /data/heisenbridge.yaml ]; then 4 | python -m heisenbridge -c /data/heisenbridge.yaml --generate --listen-address heisenbridge 5 | fi 6 | 7 | sleep 5 # wait a bit to avoid reconnect backoff during startup 8 | 9 | python -m heisenbridge -c /data/heisenbridge.yaml --listen-address 0.0.0.0 http://homeserver:8008 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=40.8.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.isort] 6 | multi_line_output = 3 7 | include_trailing_comma = true 8 | force_grid_wrap = 0 9 | use_parentheses = true 10 | ensure_newline_before_comments = true 11 | line_length = 132 12 | 13 | [tool.black] 14 | line-length = 120 15 | target-version = ['py310'] 16 | -------------------------------------------------------------------------------- /docker-compose/init/synapse-init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ## first generate homeserver config 4 | if [ ! -f /data/homeserver.yaml ]; then 5 | /start.py generate 6 | cat >> /data/homeserver.yaml <=19.0.0, <20.4 17 | ruamel.yaml >=0.15.35, <0.19 18 | mautrix >=0.20.5, <0.21 19 | python-socks[asyncio] >= 1.2.4 20 | aiohttp >=3.8.0, <4.0.0 21 | 22 | python_requires = >=3.10 23 | 24 | [options.entry_points] 25 | console_scripts = 26 | heisenbridge = heisenbridge.__main__:main 27 | 28 | [options.extras_require] 29 | dev = 30 | mypy 31 | flake8 32 | black >= 22.3.0 33 | reorder-python-imports 34 | pre-commit 35 | setuptools 36 | 37 | test = 38 | pytest 39 | 40 | [flake8] 41 | max-line-length = 132 42 | extend-ignore = E203, E721 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Toni Spets 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /heisenbridge/hidden_room.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from heisenbridge.appservice import AppService 4 | from heisenbridge.room import Room 5 | 6 | 7 | class HiddenRoom(Room): 8 | @staticmethod 9 | async def create(serv: AppService) -> "HiddenRoom": 10 | logging.debug("HiddenRoom.create(serv)") 11 | room_id = await serv.create_room("heisenbridge-hidden-room", "Invite-Sink for Heisenbridge", []) 12 | room = HiddenRoom( 13 | room_id, 14 | None, 15 | serv, 16 | [serv.user_id], 17 | [], 18 | ) 19 | await room.save() 20 | serv.register_room(room) 21 | return room 22 | 23 | def is_valid(self) -> bool: 24 | # Hidden Room usage has been explicitly disabled by user 25 | if not self.serv.config.get("use_hidden_room", True): 26 | return False 27 | 28 | # Server already has a (different) hidden room 29 | if self.serv.hidden_room and self.serv.hidden_room is not self: 30 | return False 31 | 32 | return True 33 | 34 | async def post_init(self) -> None: 35 | # Those can be huge lists, but are entirely unused. Free up some memory. 36 | self.members = [] 37 | self.displaynames = {} 38 | -------------------------------------------------------------------------------- /heisenbridge/version.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | 5 | module_dir = os.path.dirname(__file__) 6 | root_dir = module_dir + "/../" 7 | 8 | __version__ = "0.0.0" 9 | __git_version__ = None 10 | 11 | if os.path.exists(module_dir + "/version.txt"): 12 | __version__ = open(module_dir + "/version.txt").read().strip() 13 | 14 | if os.path.exists(root_dir + ".git") and shutil.which("git"): 15 | try: 16 | git_env = { 17 | "PATH": os.environ["PATH"], 18 | "HOME": os.environ["HOME"], 19 | "LANG": "C", 20 | "LC_ALL": "C", 21 | } 22 | git_bits = ( 23 | subprocess.check_output(["git", "describe", "--tags"], stderr=subprocess.DEVNULL, cwd=root_dir, env=git_env) 24 | .strip() 25 | .decode("ascii") 26 | .split("-") 27 | ) 28 | 29 | __git_version__ = git_bits[0][1:] 30 | 31 | if len(git_bits) > 1: 32 | __git_version__ += f".dev{git_bits[1]}" 33 | 34 | if len(git_bits) > 2: 35 | __git_version__ += f"+{git_bits[2]}" 36 | 37 | # always override version with git version if we have a valid version number 38 | __version__ = __git_version__ 39 | except (subprocess.SubprocessError, OSError): 40 | pass 41 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Included to allow for editable installs 2 | import importlib.util 3 | 4 | from setuptools import Command 5 | from setuptools import setup 6 | from setuptools.command.build_py import build_py 7 | from setuptools.command.sdist import sdist 8 | 9 | # pull git or local version 10 | spec = importlib.util.spec_from_file_location("version", "heisenbridge/version.py") 11 | version = importlib.util.module_from_spec(spec) 12 | spec.loader.exec_module(version) 13 | 14 | 15 | class GenerateVersionCommand(Command): 16 | description = "Generate version.txt" 17 | user_options = [] 18 | 19 | def run(self): 20 | with open("heisenbridge/version.txt", "w") as version_file: 21 | version_file.write(version.__version__) 22 | 23 | def initialize_options(self): 24 | pass 25 | 26 | def finalize_options(self): 27 | pass 28 | 29 | 30 | class BuildPyCommand(build_py): 31 | def run(self): 32 | self.run_command("gen_version") 33 | build_py.run(self) 34 | 35 | 36 | class SDistCommand(sdist): 37 | def run(self): 38 | self.run_command("gen_version") 39 | sdist.run(self) 40 | 41 | 42 | setup( 43 | version=version.__version__, 44 | cmdclass={"gen_version": GenerateVersionCommand, "build_py": BuildPyCommand, "sdist": SDistCommand}, 45 | packages=["heisenbridge"], 46 | package_data={"heisenbridge": ["version.txt"]}, 47 | ) 48 | -------------------------------------------------------------------------------- /tests/test_pills.py: -------------------------------------------------------------------------------- 1 | from heisenbridge.private_room import parse_irc_formatting 2 | 3 | 4 | def test_pills(): 5 | # simplified pill expect 6 | def pill(t): 7 | return f'{t}' 8 | 9 | def fmt(input): 10 | pills = { 11 | "foo": ("foo", "foo"), 12 | "fo0": ("Fo0", "Fo0"), 13 | "^foo^": ("^foo^", "^foo^"), 14 | "[foo]": ("[foo]", "[foo]"), 15 | "{foo}": ("{foo}", "{foo}"), 16 | } 17 | 18 | plain, formatted = parse_irc_formatting(input, pills) 19 | return formatted if formatted else plain 20 | 21 | # must always create a pill 22 | assert fmt("foo") == pill("foo") 23 | assert fmt("Fo0") == pill("Fo0") 24 | assert fmt("foo!") == pill("foo") + "!" 25 | assert fmt("foo?") == pill("foo") + "?" 26 | assert fmt("foo bar") == pill("foo") + " bar" 27 | assert fmt("foo foo foo") == pill("foo") + " " + pill("foo") + " " + pill("foo") 28 | assert fmt("foo: bar") == pill("foo") + ": bar" 29 | assert fmt("foo, bar") == pill("foo") + ", bar" 30 | assert fmt("foo; bar") == pill("foo") + "; bar" 31 | assert fmt("foo...") == pill("foo") + "..." 32 | assert fmt("foo bar") == pill("foo") + " bar" 33 | assert fmt("bar foo.") == "bar " + pill("foo") + "." 34 | assert fmt("foo. bar") == pill("foo") + ". bar" 35 | assert fmt("foo? bar") == pill("foo") + "? bar" 36 | assert fmt("^foo^:") == pill("^foo^") + ":" 37 | assert fmt("[foo],") == pill("[foo]") + "," 38 | assert fmt("{foo}?") == pill("{foo}") + "?" 39 | 40 | # anything resembling a working URL should be exempt 41 | assert fmt("foo.bar") == "foo.bar" 42 | assert fmt("https://foo.bar/foo?foo=foo&foo=foo#foo") == "https://foo.bar/foo?foo=foo&foo=foo#foo" 43 | 44 | # must never create a pill 45 | assert fmt("ba.rfoo") == "ba.rfoo" 46 | assert fmt("barfoo") == "barfoo" 47 | assert fmt("foo/") == "foo/" 48 | assert fmt("/foo") == "/foo" 49 | assert fmt("foo=bar") == "foo=bar" 50 | assert fmt("foo&bar") == "foo&bar" 51 | assert fmt("foo#bar") == "foo#bar" 52 | assert fmt("foo%bar") == "foo%bar" 53 | assert fmt("äfoo") == "äfoo" 54 | assert fmt("fooä") == "fooä" 55 | assert fmt("äfooä") == "äfooä" 56 | -------------------------------------------------------------------------------- /heisenbridge/identd.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import re 4 | import socket 5 | 6 | from heisenbridge.network_room import NetworkRoom 7 | 8 | 9 | class Identd: 10 | async def handle(self, reader, writer): 11 | try: 12 | data = await asyncio.wait_for(reader.readuntil(b"\r\n"), 10) 13 | query = data.decode() 14 | 15 | m = re.match(r"^(\d+)\s*,\s*(\d+)", query) 16 | if m: 17 | req_addr, req_port, *_ = writer.get_extra_info("peername") 18 | 19 | src_port = int(m.group(1)) 20 | dst_port = int(m.group(2)) 21 | 22 | response = f"{src_port}, {dst_port} : ERROR : NO-USER\r\n" 23 | 24 | logging.debug(f"Remote {req_addr} wants to know who is {src_port} connected to {dst_port}") 25 | 26 | """ 27 | This is a hack to workaround the issue where asyncio create_connection has not returned before 28 | identd is already requested. 29 | 30 | Proper fix would be to use our own sock that has been pre-bound but that's quite a bit of work 31 | for very little gain. 32 | """ 33 | await asyncio.sleep(0.1) 34 | 35 | for room in self.serv.find_rooms(NetworkRoom): 36 | if not room.conn or not room.conn.connected: 37 | continue 38 | 39 | remote_addr, remote_port, *_ = room.conn.transport.get_extra_info("peername") or ("", "") 40 | local_addr, local_port, *_ = room.conn.transport.get_extra_info("sockname") or ("", "") 41 | 42 | if remote_port == dst_port and local_port == src_port: 43 | response = f"{src_port}, {dst_port} : USERID : UNIX : {room.get_ident()}\r\n" 44 | break 45 | 46 | logging.debug(f"Responding with: {response}") 47 | writer.write(response.encode()) 48 | await writer.drain() 49 | except Exception: 50 | logging.debug("Identd request threw exception, ignored") 51 | finally: 52 | writer.close() 53 | 54 | async def start_listening(self, serv, port): 55 | self.serv = serv 56 | 57 | # XXX: this only works if dual stack is enabled which usually is 58 | if socket.has_ipv6: 59 | sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) 60 | sock.bind(("::", port)) 61 | self.server = await asyncio.start_server(self.handle, sock=sock, limit=128) 62 | else: 63 | self.server = await asyncio.start_server(self.handle, "0.0.0.0", port, limit=128) 64 | -------------------------------------------------------------------------------- /heisenbridge/websocket.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | 5 | import aiohttp 6 | from mautrix.types.event import Event 7 | 8 | 9 | class AppserviceWebsocket: 10 | def __init__(self, url, token, callback): 11 | self.url = url + "/_matrix/client/unstable/fi.mau.as_sync" 12 | self.headers = { 13 | "Authorization": f"Bearer {token}", 14 | "X-Mautrix-Websocket-Version": "3", 15 | } 16 | self.callback = callback 17 | 18 | async def start(self): 19 | asyncio.create_task(self._loop()) 20 | 21 | async def _loop(self): 22 | while True: 23 | try: 24 | logging.info(f"Connecting to {self.url}...") 25 | 26 | async with aiohttp.ClientSession(headers=self.headers) as sess: 27 | async with sess.ws_connect(self.url) as ws: 28 | logging.info("Websocket connected.") 29 | 30 | async for msg in ws: 31 | if msg.type != aiohttp.WSMsgType.TEXT: 32 | logging.debug("Unhandled WS message: %s", msg) 33 | continue 34 | 35 | data = msg.json() 36 | if data["status"] == "ok" and data["command"] == "transaction": 37 | logging.debug(f"Websocket transaction {data['txn_id']}") 38 | for event in data["events"]: 39 | try: 40 | await self.callback(Event.deserialize(event)) 41 | except Exception as e: 42 | logging.error(e) 43 | 44 | await ws.send_str( 45 | json.dumps( 46 | { 47 | "command": "response", 48 | "id": data["id"], 49 | "data": {}, 50 | } 51 | ) 52 | ) 53 | else: 54 | logging.warn("Unhandled WS command: %s", data) 55 | 56 | logging.info("Websocket disconnected.") 57 | except asyncio.CancelledError: 58 | logging.info("Websocket was cancelled.") 59 | return 60 | except Exception as e: 61 | logging.error(e) 62 | 63 | try: 64 | await asyncio.sleep(5) 65 | except asyncio.CancelledError: 66 | return 67 | -------------------------------------------------------------------------------- /heisenbridge/appservice.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC 3 | from abc import abstractmethod 4 | from typing import List 5 | 6 | from mautrix.api import Method 7 | from mautrix.api import Path 8 | from mautrix.errors import MNotFound 9 | 10 | 11 | class Room: 12 | pass 13 | 14 | 15 | class AppService(ABC): 16 | user_id: str 17 | server_name: str 18 | config: dict 19 | hidden_room: Room 20 | 21 | async def load(self): 22 | try: 23 | self.config.update(await self.az.intent.get_account_data("irc")) 24 | except MNotFound: 25 | await self.save() 26 | 27 | async def save(self): 28 | await self.az.intent.set_account_data("irc", self.config) 29 | 30 | async def create_room(self, name: str, topic: str, invite: List[str], restricted: str = None) -> str: 31 | req = { 32 | "visibility": "private", 33 | "name": name, 34 | "topic": topic, 35 | "invite": invite, 36 | "is_direct": False, 37 | "power_level_content_override": { 38 | "users_default": 0, 39 | "invite": 100, 40 | "kick": 100, 41 | "redact": 100, 42 | "ban": 100, 43 | "events": { 44 | "m.room.name": 0, 45 | "m.room.avatar": 0, # these work as long as rooms are private 46 | }, 47 | }, 48 | "com.beeper.auto_join_invites": True, 49 | } 50 | 51 | if restricted is not None: 52 | resp = await self.az.intent.api.request(Method.GET, Path.v3.capabilities) 53 | try: 54 | def_ver = resp["capabilities"]["m.room_versions"]["default"] 55 | except KeyError: 56 | logging.debug("Unexpected capabilities reply") 57 | def_ver = None 58 | 59 | # If room version is in range of 1..8, request v9 60 | if def_ver in [str(v) for v in range(1, 9)]: 61 | req["room_version"] = "9" 62 | 63 | req["initial_state"] = [ 64 | { 65 | "type": "m.room.join_rules", 66 | "state_key": "", 67 | "content": { 68 | "join_rule": "restricted", 69 | "allow": [{"type": "m.room_membership", "room_id": restricted}], 70 | }, 71 | } 72 | ] 73 | 74 | resp = await self.az.intent.api.request(Method.POST, Path.v3.createRoom, req) 75 | 76 | return resp["room_id"] 77 | 78 | @abstractmethod 79 | def register_room(self, room: Room): 80 | pass 81 | 82 | @abstractmethod 83 | def find_rooms(self, type=None, user_id: str = None) -> List[Room]: 84 | pass 85 | -------------------------------------------------------------------------------- /heisenbridge/command_parse.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import shlex 3 | 4 | 5 | class CommandParserFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawTextHelpFormatter): 6 | pass 7 | 8 | 9 | class CommandParserError(Exception): 10 | pass 11 | 12 | 13 | class CommandParser(argparse.ArgumentParser): 14 | def __init__(self, *args, formatter_class=CommandParserFormatter, **kwargs): 15 | super().__init__(*args, formatter_class=formatter_class, **kwargs) 16 | 17 | @property 18 | def short_description(self): 19 | return self.description.split("\n")[0] 20 | 21 | def error(self, message): 22 | raise CommandParserError(message) 23 | 24 | def print_usage(self): 25 | raise CommandParserError(self.format_usage()) 26 | 27 | def print_help(self): 28 | raise CommandParserError(self.format_help()) 29 | 30 | def exit(self, status=0, message=None): 31 | pass 32 | 33 | 34 | def split(text): 35 | commands = [] 36 | 37 | sh_split = shlex.shlex(text, posix=True, punctuation_chars=";") 38 | sh_split.commenters = "" 39 | sh_split.wordchars += "!#$%&()*+,-./:<=>?@[\\]^_`{|}~" 40 | 41 | args = [] 42 | for v in list(sh_split): 43 | if v == ";": 44 | commands.append(args) 45 | args = [] 46 | else: 47 | args.append(v) 48 | 49 | if len(args) > 0: 50 | commands.append(args) 51 | 52 | return commands 53 | 54 | 55 | class CommandManager: 56 | _commands: dict 57 | 58 | def __init__(self): 59 | self._commands = {} 60 | 61 | def register(self, cmd: CommandParser, func, aliases=None): 62 | self._commands[cmd.prog] = (cmd, func) 63 | 64 | if aliases is not None: 65 | for alias in aliases: 66 | self._commands[alias] = (cmd, func) 67 | 68 | async def trigger_args(self, args, tail=None, allowed=None, forward=None): 69 | command = args.pop(0).upper() 70 | 71 | if allowed is not None and command not in allowed: 72 | raise CommandParserError(f"Illegal command supplied: '{command}'") 73 | 74 | if command in self._commands: 75 | (cmd, func) = self._commands[command] 76 | cmd_args = cmd.parse_args(args) 77 | cmd_args._tail = tail 78 | cmd_args._forward = forward 79 | await func(cmd_args) 80 | elif command == "HELP": 81 | out = ["Following commands are supported:", ""] 82 | for name, (cmd, func) in self._commands.items(): 83 | if cmd.prog == name: 84 | out.append("\t{} - {}".format(cmd.prog, cmd.short_description)) 85 | 86 | out.append("") 87 | out.append("To get more help, add -h to any command without arguments.") 88 | 89 | raise CommandParserError("\n".join(out)) 90 | else: 91 | raise CommandParserError('Unknown command "{}", type HELP for list'.format(command)) 92 | 93 | async def trigger(self, text, tail=None, allowed=None, forward=None): 94 | for args in split(text): 95 | await self.trigger_args(args, tail, allowed, forward) 96 | tail = None 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | pytestdebug.log 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | doc/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | #poetry.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Celery stuff 106 | celerybeat-schedule 107 | celerybeat.pid 108 | 109 | # SageMath parsed files 110 | *.sage.py 111 | 112 | # Environments 113 | # .env 114 | .env/ 115 | .venv/ 116 | env/ 117 | venv/ 118 | ENV/ 119 | env.bak/ 120 | venv.bak/ 121 | pythonenv* 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # operating system-related files 145 | # file properties cache/storage on macOS 146 | *.DS_Store 147 | # thumbnail cache on Windows 148 | Thumbs.db 149 | 150 | # profiling data 151 | .prof 152 | 153 | # Direnv https://direnv.net/ 154 | \.envrc 155 | \.direnv 156 | 157 | # End of https://www.toptal.com/developers/gitignore/api/python 158 | 159 | /heisenbridge/version.txt 160 | -------------------------------------------------------------------------------- /heisenbridge/parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Dict 3 | from typing import Optional 4 | from typing import Pattern 5 | 6 | from mautrix.types import UserID 7 | from mautrix.util.formatter.formatted_string import EntityType 8 | from mautrix.util.formatter.html_reader import HTMLNode 9 | from mautrix.util.formatter.markdown_string import MarkdownString 10 | from mautrix.util.formatter.parser import MatrixParser 11 | from mautrix.util.formatter.parser import RecursionContext 12 | from mautrix.util.formatter.parser import T 13 | 14 | 15 | class IRCString(MarkdownString): 16 | def format(self, entity_type: EntityType, **kwargs) -> "IRCString": 17 | if entity_type == EntityType.BOLD: 18 | self.text = f"*{self.text}*" 19 | elif entity_type == EntityType.ITALIC: 20 | self.text = f"_{self.text}_" 21 | elif entity_type == EntityType.STRIKETHROUGH: 22 | self.text = f"~{self.text}~" 23 | elif entity_type == EntityType.UNDERLINE: 24 | self.text = self.text 25 | elif entity_type == EntityType.URL: 26 | if kwargs["url"] != self.text: 27 | self.text = f"{self.text} ({kwargs['url']})" 28 | elif entity_type == EntityType.EMAIL: 29 | self.text = self.text 30 | elif entity_type == EntityType.PREFORMATTED: 31 | self.text = re.sub(r"\n+", "\n", self.text) + "\n" 32 | elif entity_type == EntityType.INLINE_CODE: 33 | self.text = f'"{self.text}"' 34 | elif entity_type == EntityType.BLOCKQUOTE: 35 | children = self.trim().split("\n") 36 | children = [child.prepend("> ") for child in children] 37 | self.text = self.join(children, "\n").text 38 | elif entity_type == EntityType.USER_MENTION: 39 | if kwargs["displayname"] is not None: 40 | self.text = kwargs["displayname"] 41 | 42 | return self 43 | 44 | 45 | class IRCMatrixParser(MatrixParser): 46 | fs = IRCString 47 | list_bullets = ("-", "*", "+", "=") 48 | displaynames = Dict[str, str] 49 | 50 | # use .* to account for legacy empty mxid 51 | mention_regex: Pattern = re.compile("https://matrix.to/#/(@.*:.+)") 52 | 53 | def __init__(self, displaynames: Dict[str, str]) -> T: 54 | self.displaynames = displaynames 55 | 56 | async def tag_aware_parse_node(self, node: HTMLNode, ctx: RecursionContext) -> T: 57 | msgs = await self.node_to_tagged_fstrings(node, ctx) 58 | output = self.fs() 59 | prev_was_block = True 60 | for msg, tag in msgs: 61 | if tag in self.block_tags: 62 | msg = msg.trim() 63 | if not prev_was_block: 64 | output.append("\n") 65 | prev_was_block = True 66 | else: 67 | prev_was_block = False 68 | output = output.append(msg) 69 | return output.trim() 70 | 71 | async def user_pill_to_fstring(self, msg: T, user_id: UserID) -> Optional[T]: 72 | displayname = None 73 | if user_id in self.displaynames: 74 | displayname = self.displaynames[user_id] 75 | return msg.format(self.e.USER_MENTION, user_id=user_id, displayname=displayname) 76 | -------------------------------------------------------------------------------- /heisenbridge/event_queue.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | """ 5 | Buffering event queue with merging of events. 6 | """ 7 | 8 | 9 | class EventQueue: 10 | def __init__(self, callback): 11 | self._callback = callback 12 | self._events = [] 13 | self._loop = asyncio.get_running_loop() 14 | self._timer = None 15 | self._start = 0 16 | self._chain = asyncio.Queue() 17 | self._task = None 18 | self._timeout = 3600 19 | 20 | def start(self): 21 | if self._task is None: 22 | self._task = asyncio.ensure_future(self._run()) 23 | 24 | def stop(self): 25 | if self._task: 26 | self._task.cancel() 27 | self._task = None 28 | 29 | async def _run(self): 30 | while True: 31 | try: 32 | task = await self._chain.get() 33 | except asyncio.CancelledError: 34 | logging.debug("EventQueue was cancelled.") 35 | return 36 | 37 | try: 38 | await asyncio.wait_for(task, timeout=self._timeout) 39 | except asyncio.CancelledError: 40 | logging.debug("EventQueue task was cancelled.") 41 | return 42 | except asyncio.TimeoutError: 43 | logging.warning("EventQueue task timed out.") 44 | finally: 45 | self._chain.task_done() 46 | 47 | def _flush(self): 48 | events = self._events 49 | 50 | self._timer = None 51 | self._events = [] 52 | 53 | self._chain.put_nowait(self._callback(events)) 54 | 55 | def enqueue(self, event): 56 | now = self._loop.time() 57 | 58 | # always cancel timer when we enqueue 59 | if self._timer: 60 | self._timer.cancel() 61 | 62 | # stamp start time when we queue first event, always append event 63 | if len(self._events) == 0: 64 | self._start = now 65 | self._events.append(event) 66 | else: 67 | # lets see if we can merge the event 68 | prev = self._events[-1] 69 | 70 | prev_formatted = "format" in prev["content"] 71 | cur_formatted = "format" in event["content"] 72 | 73 | # calculate content length if we need to flush anyway to stay within max event size 74 | prev_len = 0 75 | if "content" in prev: 76 | if "body" in prev["content"]: 77 | prev_len += len(prev["content"]["body"]) 78 | if "formatted_body" in prev["content"]: 79 | prev_len += len(prev["content"]["formatted_body"]) 80 | 81 | if ( 82 | prev["type"] == event["type"] 83 | and prev["type"][0] != "_" 84 | and prev["user_id"] == event["user_id"] 85 | and "msgtype" in prev["content"] 86 | and prev["content"]["msgtype"] == event["content"]["msgtype"] 87 | and prev_formatted == cur_formatted 88 | and prev_len < 64_000 # a single IRC event can't overflow with this 89 | ): 90 | prev["content"]["body"] += "\n" + event["content"]["body"] 91 | if cur_formatted: 92 | prev["content"]["formatted_body"] += "
" + event["content"]["formatted_body"] 93 | else: 94 | # can't merge, force flush but enqueue the next event 95 | self._flush() 96 | self._start = now 97 | self._events.append(event) 98 | 99 | # if we have bumped ourself for a full second, flush now 100 | if now >= self._start + 1.0: 101 | self._flush() 102 | else: 103 | self._timer = self._loop.call_later(0.1, self._flush) 104 | -------------------------------------------------------------------------------- /logo/heisenbridge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 17 | 40 | 45 | 46 | 48 | 52 | 55 | 62 | 69 | 76 | 83 | 90 | 97 | 104 | 111 | 118 | 125 | 132 | 139 | 146 | 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /heisenbridge/space_room.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import List 4 | 5 | from mautrix.api import Method 6 | from mautrix.api import Path 7 | from mautrix.types import SpaceChildStateEventContent 8 | from mautrix.types.event.type import EventType 9 | 10 | from heisenbridge.room import Room 11 | 12 | 13 | class NetworkRoom: 14 | pass 15 | 16 | 17 | class SpaceRoom(Room): 18 | # pending rooms to attach during space creation 19 | pending: List[str] 20 | 21 | def init(self) -> None: 22 | super().init() 23 | 24 | self.pending = [] 25 | 26 | def is_valid(self) -> bool: 27 | # we need to know our network 28 | if self.network_id is None: 29 | return False 30 | 31 | # we are valid as long as our user is in the room 32 | if not self.in_room(self.user_id): 33 | return False 34 | 35 | return True 36 | 37 | @staticmethod 38 | def create(network: "NetworkRoom", initial_rooms: List[str]) -> "SpaceRoom": 39 | logging.debug(f"SpaceRoom.create(network='{network.id}' ({network.name}))") 40 | 41 | room = SpaceRoom( 42 | None, 43 | network.user_id, 44 | network.serv, 45 | [network.user_id, network.serv.user_id], 46 | [], 47 | ) 48 | room.name = network.name 49 | room.network = network # only used in create_finalize 50 | room.network_id = network.id 51 | room.pending += initial_rooms 52 | return room 53 | 54 | async def create_finalize(self) -> None: 55 | resp = await self.az.intent.api.request( 56 | Method.POST, 57 | Path.v3.createRoom, 58 | { 59 | "creation_content": { 60 | "type": "m.space", 61 | }, 62 | "visibility": "private", 63 | "name": self.network.name, 64 | "topic": f"Network space for {self.network.name}", 65 | "invite": [self.network.user_id], 66 | "is_direct": False, 67 | "initial_state": [ 68 | { 69 | "type": "m.space.child", 70 | "state_key": self.network.id, 71 | "content": {"via": [self.network.serv.server_name]}, 72 | } 73 | ], 74 | "power_level_content_override": { 75 | "events_default": 100, 76 | "users_default": 0, 77 | "invite": 100, 78 | "kick": 100, 79 | "redact": 100, 80 | "ban": 100, 81 | "events": { 82 | "m.room.name": 0, 83 | "m.room.avatar": 0, # these work as long as rooms are private 84 | }, 85 | }, 86 | }, 87 | ) 88 | 89 | self.id = resp["room_id"] 90 | self.serv.register_room(self) 91 | await self.save() 92 | 93 | # attach all pending rooms 94 | rooms = self.pending 95 | self.pending = [] 96 | 97 | for room_id in rooms: 98 | await self.attach(room_id) 99 | 100 | def from_config(self, config: dict) -> None: 101 | super().from_config(config) 102 | 103 | if "network_id" in config: 104 | self.network_id = config["network_id"] 105 | 106 | def to_config(self) -> dict: 107 | return { 108 | **(super().to_config()), 109 | "network_id": self.network_id, 110 | } 111 | 112 | def cleanup(self) -> None: 113 | try: 114 | network = self.serv._rooms[self.network_id] 115 | 116 | if network.space == self: 117 | network.space = None 118 | network.space_id = None 119 | asyncio.ensure_future(network.save()) 120 | logging.debug(f"Space {self.id} cleaned up from network {network.id}") 121 | else: 122 | logging.debug(f"Space room cleaned up as a duplicate for network {network.id}, probably fine.") 123 | except KeyError: 124 | logging.debug(f"Space room cleaned up with missing network {self.network_id}, probably fine.") 125 | 126 | super().cleanup() 127 | 128 | async def attach(self, room_id) -> None: 129 | # if we are attached between space request and creation just add to pending list 130 | if self.id is None: 131 | logging.debug(f"Queuing room {room_id} attachment to pending space.") 132 | self.pending.append(room_id) 133 | return 134 | 135 | logging.debug(f"Attaching room {room_id} to space {self.id}.") 136 | await self.az.intent.send_state_event( 137 | self.id, 138 | EventType.SPACE_CHILD, 139 | state_key=room_id, 140 | content=SpaceChildStateEventContent(via=[self.serv.server_name]), 141 | ) 142 | 143 | async def detach(self, room_id) -> None: 144 | if self.id is not None: 145 | logging.debug(f"Detaching room {room_id} from space {self.id}.") 146 | await self.az.intent.send_state_event( 147 | self.id, EventType.SPACE_CHILD, state_key=room_id, content=SpaceChildStateEventContent() 148 | ) 149 | elif room_id in self.pending: 150 | logging.debug(f"Removing {room_id} from space {self.id} pending queue.") 151 | self.pending.remove(room_id) 152 | 153 | async def post_init(self) -> None: 154 | try: 155 | network = self.serv._rooms[self.network_id] 156 | if network.space is not None: 157 | logging.warn( 158 | f"Network room {network.id} already has space {network.space.id} but I'm {self.id}, we are dangling." 159 | ) 160 | return 161 | 162 | network.space = self 163 | logging.debug(f"Space {self.id} attached to network {network.id}") 164 | except KeyError: 165 | logging.debug(f"Network room {self.network_id} was not found for space {self.id}, we are dangling.") 166 | self.network_id = None 167 | -------------------------------------------------------------------------------- /heisenbridge/irc.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import collections 3 | import logging 4 | 5 | from irc.client_aio import AioConnection 6 | from irc.client_aio import AioReactor 7 | from irc.client_aio import IrcProtocol 8 | from irc.connection import AioFactory 9 | 10 | 11 | class MultiQueue: 12 | def __init__(self): 13 | self._prios = [] 14 | self._ques = {} 15 | 16 | def __len__(self): 17 | return sum([len(q) for q in self._ques.values()]) 18 | 19 | def append(self, item): 20 | prio, value, tag = item 21 | 22 | if prio not in self._prios: 23 | self._prios.append(prio) 24 | self._prios.sort() 25 | self._ques[prio] = collections.deque() 26 | 27 | self._ques[prio].append(item) 28 | 29 | def get(self): 30 | for prio in self._prios: 31 | que = self._ques[prio] 32 | if len(que) > 0: 33 | return que.popleft() 34 | 35 | raise IndexError("Get called when all queues empty") 36 | 37 | def filter(self, func) -> int: 38 | filtered = 0 39 | 40 | for que in self._ques.values(): 41 | tmp = que.copy() 42 | olen = len(que) 43 | que.clear() 44 | que.extend(filter(func, tmp)) 45 | filtered += olen - len(que) 46 | 47 | return filtered 48 | 49 | 50 | # asyncio.PriorityQueue does not preserve order within priority level 51 | class OrderedPriorityQueue(asyncio.Queue): 52 | def _init(self, maxsize): 53 | self._queue = MultiQueue() 54 | 55 | def _get(self): 56 | return self._queue.get() 57 | 58 | def _put(self, item): 59 | self._queue.append(item) 60 | 61 | def remove_tag(self, tag) -> int: 62 | return self._queue.filter(lambda x: x == tag) 63 | 64 | 65 | class HeisenProtocol(IrcProtocol): 66 | ping_timeout = 300 67 | 68 | def connection_made(self, *args, **kwargs): 69 | super().connection_made(*args, **kwargs) 70 | 71 | # start aliveness check 72 | self._timer = self.loop.call_later(60, self._are_we_still_alive) 73 | self._last_data = self.loop.time() 74 | 75 | def connection_lost(self, exc): 76 | super().connection_lost(exc) 77 | self._timer.cancel() 78 | 79 | def data_received(self, *args, **kwargs): 80 | super().data_received(*args, **kwargs) 81 | self._last_data = self.loop.time() 82 | 83 | def _are_we_still_alive(self): 84 | if not self.connection or not hasattr(self.connection, "connected") or not self.connection.connected: 85 | return 86 | 87 | # no 88 | if self.loop.time() - self._last_data >= self.ping_timeout: 89 | logging.debug("Disconnecting due to no data received from server.") 90 | self.connection.disconnect("No data received.") 91 | return 92 | 93 | # re-schedule aliveness check 94 | self._timer = self.loop.call_later(self.ping_timeout / 3, self._are_we_still_alive) 95 | 96 | # yes 97 | if self.loop.time() - self._last_data < self.ping_timeout / 3: 98 | return 99 | 100 | # perhaps, ask the server 101 | logging.debug("Aliveness check failed, sending PING") 102 | self.connection.send_items("PING", self.connection.real_server_name) 103 | 104 | 105 | class HeisenConnection(AioConnection): 106 | protocol_class = HeisenProtocol 107 | 108 | def __init__(self, reactor): 109 | super().__init__(reactor) 110 | self._queue = OrderedPriorityQueue() 111 | 112 | async def expect(self, events, timeout=30): 113 | events = events if not isinstance(events, str) and not isinstance(events, int) else [events] 114 | waitable = asyncio.Event() 115 | result = None 116 | 117 | def expected(connection, event): 118 | nonlocal result, waitable 119 | result = (connection, event) 120 | waitable.set() 121 | return "NO MORE" 122 | 123 | for event in events: 124 | self.add_global_handler(event, expected, -100) 125 | 126 | try: 127 | await asyncio.wait_for(waitable.wait(), timeout) 128 | return result 129 | finally: 130 | for event in events: 131 | self.remove_global_handler(event, expected) 132 | 133 | async def connect( 134 | self, 135 | server, 136 | port, 137 | nickname, 138 | password=None, 139 | username=None, 140 | ircname=None, 141 | connect_factory=AioFactory(), 142 | ): 143 | if self.connected: 144 | self.disconnect("Changing servers") 145 | 146 | self.buffer = self.buffer_class() 147 | self.handlers = {} 148 | self.real_server_name = "" 149 | self.real_nickname = "" 150 | self.server = server 151 | self.port = port 152 | self.server_address = (server, port) 153 | self.nickname = nickname 154 | self.username = username or nickname 155 | self.ircname = ircname or nickname 156 | self.password = password 157 | self.connect_factory = connect_factory 158 | 159 | protocol_instance = self.protocol_class(self, self.reactor.loop) 160 | connection = self.connect_factory(protocol_instance, self.server_address) 161 | transport, protocol = await connection 162 | 163 | self.transport = transport 164 | self.protocol = protocol 165 | 166 | self.connected = True 167 | self._task = asyncio.ensure_future(self._run()) 168 | self.reactor._on_connect(self.protocol, self.transport) 169 | return self 170 | 171 | async def register(self): 172 | # Log on... 173 | if self.password: 174 | self.pass_(self.password) 175 | self.nick(self.nickname) 176 | self.user(self.username, self.ircname) 177 | 178 | def close(self): 179 | logging.debug("Canceling IRC event queue") 180 | self._task.cancel() 181 | super().close() 182 | 183 | async def _run(self): 184 | loop = asyncio.get_running_loop() 185 | last = loop.time() 186 | penalty = 0 187 | 188 | while True: 189 | try: 190 | (priority, string, tag) = await self._queue.get() 191 | 192 | diff = int(loop.time() - last) 193 | 194 | # zero int diff means we are going too fast 195 | if diff == 0: 196 | penalty += 1 197 | else: 198 | penalty -= diff 199 | if penalty < 0: 200 | penalty = 0 201 | 202 | super().send_raw(string) 203 | 204 | # sleep is based on message length 205 | sleep_time = max(len(string.encode()) / 512 * 6, 1.5) 206 | 207 | if penalty > 5 or sleep_time > 1.5: 208 | await asyncio.sleep(sleep_time) 209 | 210 | # this needs to be reset if we slept 211 | last = loop.time() 212 | except asyncio.CancelledError: 213 | break 214 | except Exception: 215 | logging.exception("Failed to flush IRC queue") 216 | 217 | self._queue.task_done() 218 | 219 | logging.debug("IRC event queue ended") 220 | 221 | def send_raw(self, string, priority=0, tag=None): 222 | self._queue.put_nowait((priority, string, tag)) 223 | 224 | def send_items(self, *items): 225 | priority = 0 226 | tag = None 227 | if items[0] == "NOTICE": 228 | # queue CTCP replies even lower than notices 229 | if len(items) > 2 and len(items[2]) > 1 and items[2][1] == "\001": 230 | priority = 3 231 | else: 232 | priority = 2 233 | if items[0] == "PRIVMSG": 234 | priority = 1 235 | elif items[0] == "PONG": 236 | priority = -1 237 | 238 | # tag with target to dequeue with filter 239 | if tag is None and items[0] in ["NOTICE", "PRIVMSG", "MODE", "JOIN", "PART", "KICK"]: 240 | tag = items[1].lower() 241 | 242 | self.send_raw(" ".join(filter(None, items)), priority, tag) 243 | 244 | def remove_tag(self, tag) -> int: 245 | return self._queue.remove_tag(tag) 246 | 247 | 248 | class HeisenReactor(AioReactor): 249 | connection_class = HeisenConnection 250 | 251 | def _handle_event(self, connection, event): 252 | with self.mutex: 253 | matching_handlers = sorted(self.handlers.get("all_events", []) + self.handlers.get(event.type, [])) 254 | 255 | if len(matching_handlers) == 0 and event.type != "all_raw_messages" and event.type != "pong": 256 | matching_handlers += self.handlers.get("unhandled_events", []) 257 | 258 | for handler in matching_handlers: 259 | result = handler.callback(connection, event) 260 | if result == "NO MORE": 261 | return 262 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Heisenbridge 2 | ============ 3 | 4 | a bouncer-style Matrix IRC bridge. 5 | 6 | 7 | 8 | Heisenbridge brings IRC to Matrix by creating an environment where every user connects to each network individually like they would with a traditional IRC bouncer. 9 | Simplicity is achieved by exposing IRC in the most straightforward way as possible where it makes sense so it feels familiar for long time IRC users. 10 | 11 | Please file an issue when you find something is missing or isn't working that you'd like to see fixed. Pull requests are more than welcome! 12 | 13 | Support and development discussion in [#heisenbridge:vi.fi](https://matrix.to/#/#heisenbridge:vi.fi) on Matrix or by joining [#heisenbridge](https://web.libera.chat/?channels=#heisenbridge) on [Libera.Chat](https://libera.chat) IRC network. 14 | The IRC channel is plumbed with Heisenbridge to Matrix using the relaybot mode. 15 | 16 | Features 17 | -------- 18 | * "zero configuration" - no databases or storage required 19 | * brings IRC to Matrix rather than Matrix to IRC - not annoying to folks on IRC 20 | * completely managed through admin room - just DM `@heisenbridge`! 21 | * channel management through bridge bot - type `heisenbridge: help` to get started! 22 | * online help within Matrix 23 | * access control for local and federated users 24 | * configurable IRC user synchronization in rooms (fully synced on connect, half synced on join or lazy on talk) 25 | * tested with up to 2000 users in a single channel 26 | * optional room plumbing with single puppeting on Matrix <-> relaybot on IRC 27 | * IRCnet !channels _are_ supported, you're welcome 28 | * any number of IRC networks and users technically possible 29 | * channel customization by setting the name and avatar 30 | * TLS support for networks that have it 31 | * customizable ident support 32 | * configurable pillifying of IRC nicks 33 | * long message splitting directly to IRC 34 | * smart message formatting from Matrix to IRC using IRC conventions 35 | * smart message edits from Matrix to IRC by sending only corrections 36 | * automatic identify/auth with server password or automatic command on connect 37 | * SASL plain authentication 38 | * CertFP authentication 39 | * CTCP support 40 | * SOCKS proxy configuration per server 41 | * bridge managed spaces to organize your channels and PMs within a network 42 | 43 | Comparison 44 | ---------- 45 | 46 | To help understand where Heisenbridge is a good fit here's a feature matrix of some of the key differences between other popular solutions: 47 | 48 | | | Matrix | IRC | Appservice | Ratelimit | Self-host | Permissions | 49 | |-------------------------|:------:|:----:|:----------:|:---------:|:---------:|--------------| 50 | | Heisenbridge (bouncer) | one | one | yes | no | yes | IRC only | 51 | | Heisenbridge (relaybot) | many | one | yes | IRC | yes | separate | 52 | | matrix-appservice-irc | many | many | yes | no | no | synchronized | 53 | | matterbridge | one | one | no | both | only | separate | 54 | 55 | **Matrix** = actual users on Matrix 56 | **IRC** = connected users to IRC from Matrix 57 | **Appservice** = runs as an appservice (requires a homeserver) 58 | **Ratelimit** = perceived ratelimiting in default setup (Matrix/IRC) 59 | **Self-host** = is it recommended to run your own instance 60 | **Permissions** = how room/channel permissions are managed (Matrix/IRC) 61 | 62 | Heisenbridge in bouncer mode has one direct Matrix user per IRC connection and IRC users are puppeted on Matrix. 63 | The Matrix users identity is directly mapped to IRC and they are in full control. 64 | There are no Matrix side permission management in this mode as all channels and private messages are between the Matrix user and the bridge. 65 | Multiple authorized Matrix users can use the bridge separately to create their own IRC connections. 66 | 67 | Heisenbridge in relaybot mode is part of a larger Matrix room and puppets everyone from IRC separately but still has only one IRC connection for relaying messages from Matrix. 68 | Matrix users' identities are only relayed through the messages they send. 69 | Permissions in this mode are separate meaning the bridge needs to be given explicit permission to join a Matrix room if it's not public. 70 | The bridge only requires enough power levels to invite puppets (if the room is invite-only) and for them to be able to talk with the default power level they get. 71 | Single authorized Matrix user manages the relaybot with Heisenbridge. 72 | 73 | [matrix-appservice-irc](https://github.com/matrix-org/matrix-appservice-irc) runs in full single puppeting mode where both IRC and Matrix users are puppeted both ways. 74 | The Matrix users identity is directly mapped to IRC and they are in full control. 75 | This method of bridging creates multiple IRC connections which makes is mostly transparent for both sides of the bridge. 76 | As the room and channel permissions are synchronized between IRC and Matrix (where applicable) means a Matrix user joining a Matrix room needs to be able to connect and join the IRC channel through the bridge as well. 77 | Any Matrix user joining plumbed or portal IRC rooms are automatically connected to the IRC network. 78 | 79 | [matterbridge](https://github.com/42wim/matterbridge) is a bot which connects to each network as a single user and is fairly easy and quick to setup by anyone. 80 | Both IRC and Matrix users' identities are only relayed through the messages they send. 81 | The bot operator manages everything and does not require any user interaction on either side. 82 | 83 | PyPI 84 | ---- 85 | GitHub releases are automatically published to [PyPI](https://pypi.org/project/heisenbridge/): 86 | 87 | ```sh 88 | pip install heisenbridge 89 | ``` 90 | 91 | Docker 92 | ------ 93 | The master branch is automatically published to [Docker Hub](https://hub.docker.com/r/hif1/heisenbridge): 94 | ```sh 95 | docker pull hif1/heisenbridge 96 | docker run --rm hif1/heisenbridge -h 97 | ``` 98 | 99 | Each GitHub release is also tagged as `x.y.z`, `x.y` and `x`. 100 | 101 | An example docker-compose setup is in [docker-compose/](docker-compose/). 102 | 103 | Additionally, if you use [matrix-docker-ansible-deploy](https://github.com/spantaleev/matrix-docker-ansible-deploy) to deploy your Synapse server, you can use it to integrate Heisenbridge as well - just follow the [relevant docs](https://github.com/spantaleev/matrix-docker-ansible-deploy/blob/master/docs/configuring-playbook-bridge-heisenbridge.md) 104 | 105 | Usage 106 | ----- 107 | ``` 108 | usage: python -m heisenbridge [-h] [-v] (-c CONFIG | --version) 109 | [-l LISTEN_ADDRESS] [-p LISTEN_PORT] [-u UID] 110 | [-g GID] [-i] [--identd-port IDENTD_PORT] 111 | [--generate] [--generate-compat] [--reset] 112 | [--safe-mode] [-o OWNER] 113 | [homeserver] 114 | 115 | a bouncer-style Matrix IRC bridge (v1.14.5.dev4+gb9c79bb) 116 | 117 | positional arguments: 118 | homeserver URL of Matrix homeserver (default: 119 | http://localhost:8008) 120 | 121 | options: 122 | -h, --help show this help message and exit 123 | -v, --verbose logging verbosity level: once is info, twice is debug 124 | (default: 0) 125 | -c CONFIG, --config CONFIG 126 | registration YAML file path, must be writable if 127 | generating (default: None) 128 | --version show bridge version 129 | -l LISTEN_ADDRESS, --listen-address LISTEN_ADDRESS 130 | bridge listen address (default: as specified in url in 131 | config, 127.0.0.1 otherwise) (default: None) 132 | -p LISTEN_PORT, --listen-port LISTEN_PORT 133 | bridge listen port (default: as specified in url in 134 | config, 9898 otherwise) (default: None) 135 | -u UID, --uid UID user id to run as (default: None) 136 | -g GID, --gid GID group id to run as (default: None) 137 | -i, --identd enable identd service (default: False) 138 | --identd-port IDENTD_PORT 139 | identd listen port (default: 113) 140 | --generate generate registration YAML for Matrix homeserver 141 | (Synapse) 142 | --generate-compat generate registration YAML for Matrix homeserver 143 | (Dendrite and Conduit) 144 | --reset reset ALL bridge configuration from homeserver and 145 | exit 146 | --safe-mode prevent appservice from leaving invalid rooms on 147 | startup (for debugging) (default: False) 148 | -o OWNER, --owner OWNER 149 | set owner MXID (eg: @user:homeserver) or first talking 150 | local user will claim the bridge (default: None) 151 | ``` 152 | 153 | Generate a registration file to use with your homeserver using the `--generate` switch. 154 | If you are using Dendrite or Conduit prefer `--generate-compat` as otherwise you can't talk with Heisenbridge. 155 | 156 | **With the amount of showstopper bugs in Dendrite it's not officially supported by Heisenbridge at this time. When they get fixed this notice will be removed. If you hit a bug on Dendrite, please reproduce it on Synapse or Conduit before reporting it against Heisenbridge. Thank you.** 157 | 158 | Both your homeserver and Heisenbridge use the same registration file to configure their shared secrets. 159 | With Heisenbridge you need to use the generated registration file with the `--config` switch on startup. 160 | If you are running Docker a shared volume mount is adviced for the registration file that both containers can access. 161 | 162 | Communication between the homeserver and any bridge is bi-directional and requires that both can access each other directly over the network. 163 | By default Heisenbridge expects your homeserver to be accessible on localhost port 8008/TCP and the bridge itself listens on localhost port 9898/TCP. 164 | You can override these defaults using the appropriate command line options but prefer local addresses when possible. 165 | If you are running Docker the homeserver is likely on a different hostname inside the Docker network so change it accordingly by setting the positional argument on startup. 166 | 167 | Please note that the URL for Heisenbridge in the registration file is used by the homeserver to connect to it so make sure it is correct and accessible from where the homeserver is running. 168 | 169 | You also need to make sure the configuration of your homeserver does not force all rooms to be created with encryption on, as Heisenbridge currently does not support encryption. 170 | If you force encryption, Heisenbridge will be unable to respond to your commands. 171 | For example, if you use Synapse, make sure `encryption_enabled_by_default_for_room_type` is set to `off`. You can still enable encryption in other rooms, it will just not be the default. 172 | 173 | If for whatever reason you run Heisenbridge over the internet and require HTTPS you need to put Heisenbridge behind a reverse proxy that does TLS termination as it doesn't itself support loading a TLS certificate. 174 | 175 | For [Synapse](https://github.com/matrix-org/synapse) see their [installation instructions](https://github.com/matrix-org/synapse/blob/develop/docs/application_services.md) for appservices. 176 | 177 | For [Conduit](https://gitlab.com/famedly/conduit) see their [installation instructions](https://gitlab.com/famedly/conduit/-/blob/next/APPSERVICES.md) for appservices. 178 | 179 | Install 180 | ------- 181 | 182 | 1. Install Python 3.10 or newer 183 | 2. Install dependencies in virtualenv 184 | 185 | ```bash 186 | virtualenv venv 187 | source venv/bin/activate 188 | pip install heisenbridge 189 | ``` 190 | 191 | 3. Generate registration YAML 192 | 193 | ```bash 194 | python -m heisenbridge -c /path/to/synapse/config/heisenbridge.yaml --generate 195 | ``` 196 | 197 | 4. Add `heisenbridge.yaml` to Synapse appservice list 198 | 5. (Re)start Synapse 199 | 6. Start Heisenbridge 200 | 201 | ```bash 202 | python -m heisenbridge -c /path/to/synapse/config/heisenbridge.yaml 203 | ``` 204 | 205 | 7. Start a DM with `@heisenbridge:your.homeserver` to get online usage help. Example commands to join a new network: 206 | 207 | ``` 208 | ADDNETWORK IRCnet 209 | ADDSERVER IRCnet ssl.ircnet.io 6697 --tls 210 | OPEN IRCnet 211 | ``` 212 | 213 | You will be invited to a room where you can `CONNECT` and `JOIN #some-channel` 214 | 215 | To update your installation, run `pip install --upgrade heisenbridge` 216 | 217 | Develop 218 | ------- 219 | 220 | 1. Install Python 3.10 or newer 221 | 2. Install dependencies 222 | 223 | ```bash 224 | virtualenv venv 225 | source venv/bin/activate 226 | pip install -e .[dev,test] 227 | ``` 228 | 229 | 3. (Optional) Set up pre-commit hooks 230 | 231 | ```bash 232 | pre-commit install 233 | ``` 234 | 235 | The pre-commit hooks are run by the CI as well. 236 | -------------------------------------------------------------------------------- /heisenbridge/plumbed_room.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import re 4 | from typing import Optional 5 | 6 | from irc.modes import parse_channel_modes 7 | from mautrix.errors import MatrixRequestError 8 | from mautrix.types import Membership 9 | from mautrix.types import MessageEvent 10 | from mautrix.types import MessageType 11 | from mautrix.types import TextMessageEventContent 12 | 13 | from heisenbridge.channel_room import ChannelRoom 14 | from heisenbridge.command_parse import CommandParser 15 | from heisenbridge.private_room import parse_irc_formatting 16 | 17 | 18 | class NetworkRoom: 19 | pass 20 | 21 | 22 | def connected(f): 23 | def wrapper(*args, **kwargs): 24 | self = args[0] 25 | 26 | if not self.network or not self.network.conn or not self.network.conn.connected: 27 | return asyncio.sleep(0) 28 | 29 | return f(*args, **kwargs) 30 | 31 | return wrapper 32 | 33 | 34 | def send_relaymsg(room, func, sender): 35 | def wrapper(target, message): 36 | message = f":\001ACTION {message}\001" if func == room.network.conn.action else f":{message}" 37 | room.network.conn.send_items("RELAYMSG", target, f"{sanitize_irc_nick(sender)}/{room.relaytag}", message) 38 | 39 | return wrapper 40 | 41 | 42 | def sanitize_irc_nick(nick): 43 | return re.sub(r"[^A-Za-z0-9_\[\]|{}-]", "", nick) 44 | 45 | 46 | class PlumbedRoom(ChannelRoom): 47 | max_lines = 5 48 | use_pastebin = True 49 | use_reacts = True 50 | use_displaynames = True 51 | use_disambiguation = True 52 | use_zwsp = False 53 | allow_notice = False 54 | force_forward = True 55 | topic_sync = None 56 | relaytag = "m" 57 | 58 | def init(self) -> None: 59 | super().init() 60 | 61 | cmd = CommandParser( 62 | prog="DISPLAYNAMES", description="enable or disable use of displaynames in relayed messages" 63 | ) 64 | cmd.add_argument("--enable", dest="enabled", action="store_true", help="Enable displaynames") 65 | cmd.add_argument( 66 | "--disable", dest="enabled", action="store_false", help="Disable displaynames (fallback to MXID)" 67 | ) 68 | cmd.set_defaults(enabled=None) 69 | self.commands.register(cmd, self.cmd_displaynames) 70 | 71 | cmd = CommandParser( 72 | prog="DISAMBIGUATION", description="enable or disable disambiguation of conflicting displaynames" 73 | ) 74 | cmd.add_argument( 75 | "--enable", dest="enabled", action="store_true", help="Enable disambiguation (postfix with MXID)" 76 | ) 77 | cmd.add_argument("--disable", dest="enabled", action="store_false", help="Disable disambiguation") 78 | cmd.set_defaults(enabled=None) 79 | self.commands.register(cmd, self.cmd_disambiguation) 80 | 81 | cmd = CommandParser(prog="ZWSP", description="enable or disable Zero-Width-Space anti-ping") 82 | cmd.add_argument("--enable", dest="enabled", action="store_true", help="Enable ZWSP anti-ping") 83 | cmd.add_argument("--disable", dest="enabled", action="store_false", help="Disable ZWSP anti-ping") 84 | cmd.set_defaults(enabled=None) 85 | self.commands.register(cmd, self.cmd_zwsp) 86 | 87 | cmd = CommandParser(prog="NOTICERELAY", description="enable or disable relaying of Matrix notices to IRC") 88 | cmd.add_argument("--enable", dest="enabled", action="store_true", help="Enable notice relay") 89 | cmd.add_argument("--disable", dest="enabled", action="store_false", help="Disable notice relay") 90 | cmd.set_defaults(enabled=None) 91 | self.commands.register(cmd, self.cmd_noticerelay) 92 | 93 | cmd = CommandParser(prog="TOPIC", description="show or set channel topic and configure sync mode") 94 | cmd.add_argument("--sync", choices=["off", "irc", "matrix", "any"], help="Topic sync targets, defaults to off") 95 | cmd.add_argument("text", nargs="*", help="topic text if setting") 96 | self.commands.register(cmd, self.cmd_topic) 97 | 98 | cmd = CommandParser(prog="RELAYTAG", description="set RELAYMSG tag if supported by server") 99 | cmd.add_argument("tag", nargs="?", help="new tag") 100 | self.commands.register(cmd, self.cmd_relaytag) 101 | 102 | self.mx_register("m.room.topic", self._on_mx_room_topic) 103 | 104 | def is_valid(self) -> bool: 105 | # we are valid as long as the appservice is in the room 106 | if not self.in_room(self.serv.user_id): 107 | return False 108 | 109 | return True 110 | 111 | @staticmethod 112 | async def create(network: "NetworkRoom", id: str, channel: str, key: str) -> "ChannelRoom": 113 | logging.debug(f"PlumbedRoom.create(network='{network.name}', id='{id}', channel='{channel}', key='{key}'") 114 | 115 | network.send_notice(f"Joining room {id} to initiate plumb...") 116 | try: 117 | room_id = await network.az.intent.join_room(id) 118 | except MatrixRequestError as e: 119 | network.send_notice(f"Failed to join room: {str(e)}") 120 | return 121 | 122 | network.send_notice(f"Joined room {room_id}, refreshing member state...") 123 | await network.az.intent.get_room_members(room_id) 124 | network.send_notice(f"Got state for room {room_id}, plumbing...") 125 | 126 | joined = await network.az.state_store.get_member_profiles(room_id, (Membership.JOIN,)) 127 | banned = await network.az.state_store.get_members(room_id, (Membership.BAN,)) 128 | 129 | room = PlumbedRoom(room_id, network.user_id, network.serv, joined, banned) 130 | room.name = channel.lower() 131 | room.key = key 132 | room.network = network 133 | room.network_id = network.id 134 | room.network_name = network.name 135 | 136 | # stamp global member sync setting at room creation time 137 | room.member_sync = network.serv.config["member_sync"] 138 | 139 | room.max_lines = network.serv.config["max_lines"] 140 | room.use_pastebin = network.serv.config["use_pastebin"] 141 | room.use_reacts = network.serv.config["use_reacts"] 142 | 143 | for user_id, member in joined.items(): 144 | if member.displayname is not None: 145 | room.displaynames[user_id] = member.displayname 146 | 147 | network.serv.register_room(room) 148 | network.rooms[room.name] = room 149 | await room.save() 150 | 151 | network.send_notice(f"Plumbed {room_id} to {channel}, to unplumb just kick me out.") 152 | return room 153 | 154 | def from_config(self, config: dict) -> None: 155 | super().from_config(config) 156 | 157 | if "use_displaynames" in config: 158 | self.use_displaynames = config["use_displaynames"] 159 | 160 | if "use_disambiguation" in config: 161 | self.use_disambiguation = config["use_disambiguation"] 162 | 163 | if "use_zwsp" in config: 164 | self.use_zwsp = config["use_zwsp"] 165 | 166 | if "allow_notice" in config: 167 | self.allow_notice = config["allow_notice"] 168 | 169 | if "topic_sync" in config: 170 | self.topic_sync = config["topic_sync"] 171 | 172 | if "relaytag" in config: 173 | self.relaytag = config["relaytag"] 174 | 175 | def to_config(self) -> dict: 176 | return { 177 | **(super().to_config()), 178 | "use_displaynames": self.use_displaynames, 179 | "use_disambiguation": self.use_disambiguation, 180 | "use_zwsp": self.use_zwsp, 181 | "allow_notice": self.allow_notice, 182 | "topic_sync": self.topic_sync, 183 | "relaytag": self.relaytag, 184 | } 185 | 186 | # topic updates from channel state replies are ignored because formatting changes 187 | def set_topic(self, topic: str, user_id: Optional[str] = None) -> None: 188 | pass 189 | 190 | def on_topic(self, conn, event) -> None: 191 | self.send_notice("{} changed the topic".format(event.source.nick)) 192 | if conn.real_nickname != event.source.nick and self.topic_sync in ["matrix", "any"]: 193 | (plain, formatted) = parse_irc_formatting(event.arguments[0]) 194 | super().set_topic(plain) 195 | 196 | @connected 197 | async def _on_mx_room_topic(self, event) -> None: 198 | if event.sender != self.serv.user_id and self.topic_sync in ["irc", "any"]: 199 | topic = re.sub(r"[\r\n]", " ", event.content.topic) 200 | self.network.conn.topic(self.name, topic) 201 | 202 | @connected 203 | async def on_mx_message(self, event) -> None: 204 | sender = str(event.sender) 205 | (name, server) = sender.split(":", 1) 206 | 207 | # ignore self messages 208 | if sender == self.serv.user_id: 209 | return 210 | 211 | # prevent re-sending federated messages back 212 | if name.startswith("@" + self.serv.puppet_prefix) and server == self.serv.server_name: 213 | return 214 | 215 | # add ZWSP to sender to avoid pinging on IRC 216 | if self.use_zwsp: 217 | sender = f"{name[:2]}\u200B{name[2:]}:{server[:1]}\u200B{server[1:]}" 218 | 219 | if self.use_displaynames and event.sender in self.displaynames: 220 | sender_displayname = self.displaynames[event.sender] 221 | 222 | # ensure displayname is unique 223 | if self.use_disambiguation: 224 | for user_id, displayname in self.displaynames.items(): 225 | if user_id != event.sender and displayname == sender_displayname: 226 | sender_displayname += f" ({sender})" 227 | break 228 | 229 | # add ZWSP if displayname matches something on IRC 230 | if self.use_zwsp and len(sender_displayname) > 1: 231 | sender_displayname = f"{sender_displayname[:1]}\u200B{sender_displayname[1:]}" 232 | 233 | sender = sender_displayname 234 | 235 | # limit plumbed sender max length to 100 characters 236 | sender = sender[:100] 237 | 238 | if event.content.msgtype.is_media: 239 | # process media event like it was a text message 240 | if event.content.filename and event.content.filename != event.content.body: 241 | new_body = self.serv.mxc_to_url(event.content.url, event.content.filename) + "\n" + event.content.body 242 | else: 243 | new_body = self.serv.mxc_to_url(event.content.url, event.content.body) 244 | media_event = MessageEvent( 245 | sender=event.sender, 246 | type=None, 247 | room_id=None, 248 | event_id=None, 249 | timestamp=None, 250 | content=TextMessageEventContent(body=new_body), 251 | ) 252 | await self.relay_message(media_event, self.network.conn.privmsg, sender) 253 | 254 | if self.use_reacts: 255 | self.react(event.event_id, "\U0001F517") # link 256 | self.media.append([event.event_id, event.content.url]) 257 | await self.save() 258 | elif event.content.msgtype == MessageType.EMOTE: 259 | await self.relay_message(event, self.network.conn.action, sender) 260 | elif event.content.msgtype == MessageType.TEXT: 261 | await self.relay_message(event, self.network.conn.privmsg, sender) 262 | elif event.content.msgtype == MessageType.NOTICE and self.allow_notice: 263 | await self.relay_message(event, self.network.conn.notice, sender) 264 | 265 | await self.az.intent.send_receipt(event.room_id, event.event_id) 266 | 267 | async def relay_message(self, event, func, sender): 268 | prefix = f"{sender} " if str(event.content.msgtype) == "m.emote" else f"<{sender}> " 269 | 270 | # if we have relaymsg cap and it's not a notice, add more abstraction 271 | if "draft/relaymsg" in self.network.caps_enabled and event.content.msgtype != MessageType.NOTICE: 272 | func = send_relaymsg(self, func, sender) 273 | prefix = None 274 | 275 | await self._send_message(event, func, prefix) 276 | 277 | @connected 278 | async def on_mx_ban(self, user_id) -> None: 279 | nick = self.serv.nick_from_irc_user_id(self.network.name, user_id) 280 | if nick is None: 281 | return 282 | 283 | # best effort kick and ban 284 | self.network.conn.mode(self.name, f"+b {nick}!*@*") 285 | self.network.conn.kick(self.name, nick, "You have been banned on Matrix") 286 | 287 | @connected 288 | async def on_mx_unban(self, user_id) -> None: 289 | nick = self.serv.nick_from_irc_user_id(self.network.name, user_id) 290 | if nick is None: 291 | return 292 | 293 | # best effort unban 294 | self.network.conn.mode(self.name, f"-b {nick}!*@*") 295 | 296 | @connected 297 | async def on_mx_leave(self, user_id) -> None: 298 | nick = self.serv.nick_from_irc_user_id(self.network.name, user_id) 299 | if nick is None: 300 | return 301 | 302 | # best effort kick 303 | if self.is_on_channel(nick): 304 | self.network.conn.kick(self.name, nick, "You have been kicked on Matrix") 305 | 306 | def pills(self): 307 | # if pills are disabled, don't generate any 308 | if self.network.pills_length < 1: 309 | return None 310 | 311 | ret = super().pills() 312 | 313 | # remove the bot from pills as it may cause confusion 314 | nick = self.network.conn.real_nickname.lower() 315 | if nick in ret: 316 | del ret[nick] 317 | 318 | return ret 319 | 320 | async def cmd_displaynames(self, args) -> None: 321 | if args.enabled is not None: 322 | self.use_displaynames = args.enabled 323 | await self.save() 324 | 325 | self.send_notice(f"Displaynames are {'enabled' if self.use_displaynames else 'disabled'}") 326 | 327 | async def cmd_disambiguation(self, args) -> None: 328 | if args.enabled is not None: 329 | self.use_disambiguation = args.enabled 330 | await self.save() 331 | 332 | self.send_notice(f"Dismabiguation is {'enabled' if self.use_disambiguation else 'disabled'}") 333 | 334 | async def cmd_zwsp(self, args) -> None: 335 | if args.enabled is not None: 336 | self.use_zwsp = args.enabled 337 | await self.save() 338 | 339 | self.send_notice(f"Zero-Width-Space anti-ping is {'enabled' if self.use_zwsp else 'disabled'}") 340 | 341 | async def cmd_noticerelay(self, args) -> None: 342 | if args.enabled is not None: 343 | self.allow_notice = args.enabled 344 | await self.save() 345 | 346 | self.send_notice(f"Notice relay is {'enabled' if self.allow_notice else 'disabled'}") 347 | 348 | async def cmd_topic(self, args) -> None: 349 | if args.sync is None: 350 | self.network.conn.topic(self.name, " ".join(args.text)) 351 | return 352 | 353 | self.topic_sync = args.sync if args.sync != "off" else None 354 | self.send_notice(f"Topic sync is {self.topic_sync if self.topic_sync else 'off'}") 355 | await self.save() 356 | 357 | async def cmd_relaytag(self, args) -> None: 358 | if args.tag: 359 | self.relaytag = sanitize_irc_nick(args.tag) 360 | await self.save() 361 | 362 | self.send_notice(f"Relay tag is '{self.relaytag}'") 363 | 364 | def on_mode(self, conn, event) -> None: 365 | super().on_mode(conn, event) 366 | 367 | # when we get ops (or half-ops) get current ban list to see if we need to ban someone that has been banned on matrix 368 | modes = list(event.arguments) 369 | for sign, key, value in parse_channel_modes(" ".join(modes)): 370 | if sign == "+" and key in ["o", "h"] and value == self.network.conn.real_nickname: 371 | self.network.conn.mode(self.name, "+b") 372 | 373 | def on_endofbanlist(self, conn, event) -> None: 374 | masks = [ban[0].lower() for ban in self.bans_buffer] 375 | super().on_endofbanlist(conn, event) 376 | 377 | # add any nick bans that are missing from IRC 378 | for user_id in self.bans: 379 | nick = self.serv.nick_from_irc_user_id(self.network.name, user_id) 380 | if nick is None: 381 | continue 382 | 383 | mask = f"{nick}!*@*" 384 | if mask not in masks: 385 | self.network.conn.mode(self.name, f"+b {mask}") 386 | if self.is_on_channel(nick): 387 | self.network.conn.kick(self.name, nick) 388 | -------------------------------------------------------------------------------- /heisenbridge/room.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from abc import ABC 4 | from collections import defaultdict 5 | from typing import Callable 6 | from typing import Dict 7 | from typing import List 8 | from typing import Optional 9 | 10 | from mautrix.appservice import AppService as MauService 11 | from mautrix.types import Membership 12 | from mautrix.types.event.type import EventType 13 | 14 | from heisenbridge.appservice import AppService 15 | from heisenbridge.event_queue import EventQueue 16 | from heisenbridge.parser import IRCMatrixParser 17 | 18 | 19 | class RoomInvalidError(Exception): 20 | pass 21 | 22 | 23 | class Room(ABC): 24 | az: MauService 25 | id: str 26 | user_id: str 27 | serv: AppService 28 | members: List[str] 29 | lazy_members: Optional[Dict[str, str]] 30 | hidden_room_id: Optional[str] 31 | bans: List[str] 32 | displaynames: Dict[str, str] 33 | parser: IRCMatrixParser 34 | 35 | _mx_handlers: Dict[str, List[Callable[[dict], bool]]] 36 | _queue: EventQueue 37 | 38 | def __init__(self, id: str, user_id: str, serv: AppService, members: List[str], bans: List[str]): 39 | self.id = id 40 | self.user_id = user_id 41 | self.serv = serv 42 | self.members = list(members) 43 | self.bans = list(bans) if bans else [] 44 | self.lazy_members = None 45 | self.hidden_room_id = None 46 | self.displaynames = {} 47 | self.last_messages = defaultdict(str) 48 | self.parser = IRCMatrixParser(self.displaynames) 49 | 50 | self._mx_handlers = {} 51 | self._queue = EventQueue(self._flush_events) 52 | 53 | # start event queue 54 | if self.id: 55 | self._queue.start() 56 | 57 | # we track room members 58 | self.mx_register("m.room.member", self._on_mx_room_member) 59 | 60 | self.init() 61 | 62 | @classmethod 63 | def init_class(cls, az: MauService): 64 | cls.az = az 65 | 66 | async def post_init(self): 67 | pass 68 | 69 | def from_config(self, config: dict) -> None: 70 | pass 71 | 72 | def init(self) -> None: 73 | pass 74 | 75 | def is_valid(self) -> bool: 76 | return True 77 | 78 | def cleanup(self): 79 | self._queue.stop() 80 | 81 | def to_config(self) -> dict: 82 | return {} 83 | 84 | async def save(self) -> None: 85 | config = self.to_config() 86 | config["type"] = type(self).__name__ 87 | config["user_id"] = self.user_id 88 | await self.az.intent.set_account_data("irc", config, self.id) 89 | 90 | def mx_register(self, type: str, func: Callable[[dict], bool]) -> None: 91 | if type not in self._mx_handlers: 92 | self._mx_handlers[type] = [] 93 | 94 | self._mx_handlers[type].append(func) 95 | 96 | async def on_mx_event(self, event: dict) -> None: 97 | handlers = self._mx_handlers.get(str(event.type), [self._on_mx_unhandled_event]) 98 | 99 | for handler in handlers: 100 | await handler(event) 101 | 102 | def in_room(self, user_id): 103 | return user_id in self.members 104 | 105 | async def on_mx_ban(self, user_id) -> None: 106 | pass 107 | 108 | async def on_mx_unban(self, user_id) -> None: 109 | pass 110 | 111 | async def on_mx_leave(self, user_id) -> None: 112 | pass 113 | 114 | async def _on_mx_unhandled_event(self, event: dict) -> None: 115 | pass 116 | 117 | async def _on_mx_room_member(self, event: dict) -> None: 118 | if event.content.membership in [Membership.LEAVE, Membership.BAN] and event.state_key in self.members: 119 | self.members.remove(event.state_key) 120 | if event.state_key in self.displaynames: 121 | del self.displaynames[event.state_key] 122 | if event.state_key in self.last_messages: 123 | del self.last_messages[event.state_key] 124 | 125 | if not self.is_valid(): 126 | raise RoomInvalidError( 127 | f"Room {self.id} ended up invalid after membership change, returning false from event handler." 128 | ) 129 | 130 | if event.content.membership == Membership.LEAVE: 131 | if event.state_key in self.bans: 132 | self.bans.remove(event.state_key) 133 | await self.on_mx_unban(event.state_key) 134 | else: 135 | await self.on_mx_leave(event.state_key) 136 | 137 | if event.content.membership == Membership.BAN: 138 | if event.state_key not in self.bans: 139 | self.bans.append(event.state_key) 140 | 141 | await self.on_mx_ban(event.state_key) 142 | 143 | if event.content.membership == Membership.JOIN: 144 | if event.state_key not in self.members: 145 | self.members.append(event.state_key) 146 | 147 | if event.content.displayname is not None: 148 | self.displaynames[event.state_key] = str(event.content.displayname) 149 | elif event.state_key in self.displaynames: 150 | del self.displaynames[event.state_key] 151 | 152 | async def _join(self, user_id, nick=None): 153 | if self.hidden_room_id: 154 | await self.az.intent.user(user_id).ensure_joined(self.hidden_room_id) 155 | 156 | await self.az.intent.user(user_id).ensure_joined(self.id, ignore_cache=True) 157 | 158 | self.members.append(user_id) 159 | if nick is not None: 160 | self.displaynames[user_id] = nick 161 | 162 | async def _flush_events(self, events): 163 | for event in events: 164 | try: 165 | if event["type"] == "_join": 166 | if self.lazy_members is not None: 167 | self.lazy_members[event["user_id"]] = event["nick"] 168 | 169 | if event["user_id"] not in self.members: 170 | if not event["lazy"]: 171 | await self._join(event["user_id"], event["nick"]) 172 | elif event["type"] == "_leave": 173 | if self.lazy_members is not None and event["user_id"] in self.lazy_members: 174 | del self.lazy_members[event["user_id"]] 175 | 176 | if event["user_id"] in self.members: 177 | if event["reason"] is not None: 178 | await self.az.intent.user(event["user_id"]).kick_user( 179 | self.id, event["user_id"], event["reason"] 180 | ) 181 | else: 182 | await self.az.intent.user(event["user_id"]).leave_room(self.id) 183 | if event["user_id"] in self.members: 184 | self.members.remove(event["user_id"]) 185 | if event["user_id"] in self.displaynames: 186 | del self.displaynames[event["user_id"]] 187 | elif event["type"] == "_rename": 188 | old_irc_user_id = self.serv.irc_user_id(self.network.name, event["old_nick"]) 189 | new_irc_user_id = self.serv.irc_user_id(self.network.name, event["new_nick"]) 190 | 191 | # if we are lazy loading and this user has never spoken, update that 192 | if ( 193 | self.lazy_members is not None 194 | and old_irc_user_id in self.lazy_members 195 | and old_irc_user_id not in self.members 196 | ): 197 | del self.lazy_members[old_irc_user_id] 198 | self.lazy_members[new_irc_user_id] = event["new_nick"] 199 | continue 200 | 201 | # this event is created for all rooms, skip if irrelevant 202 | if old_irc_user_id not in self.members: 203 | continue 204 | 205 | # always ensure new irc user id is in lazy list 206 | if self.lazy_members is not None: 207 | self.lazy_members[new_irc_user_id] = event["new_nick"] 208 | 209 | # check if we can just update the displayname 210 | if old_irc_user_id != new_irc_user_id: 211 | # ensure we have the new puppet 212 | await self.serv.ensure_irc_user_id(self.network.name, event["new_nick"]) 213 | 214 | # old puppet away 215 | await self.az.intent.user(old_irc_user_id).kick_user( 216 | self.id, old_irc_user_id, f"Changing nick to {event['new_nick']}" 217 | ) 218 | self.members.remove(old_irc_user_id) 219 | if old_irc_user_id in self.displaynames: 220 | del self.displaynames[old_irc_user_id] 221 | 222 | # new puppet in 223 | if new_irc_user_id not in self.members: 224 | await self._join(new_irc_user_id, event["new_nick"]) 225 | 226 | elif event["type"] == "_kick": 227 | if event["user_id"] in self.members: 228 | await self.az.intent.kick_user(self.id, event["user_id"], event["reason"]) 229 | self.members.remove(event["user_id"]) 230 | if event["user_id"] in self.displaynames: 231 | del self.displaynames[event["user_id"]] 232 | elif event["type"] == "_ensure_irc_user_id": 233 | await self.serv.ensure_irc_user_id(event["network"], event["nick"]) 234 | elif "state_key" in event: 235 | intent = self.az.intent 236 | 237 | if event["user_id"]: 238 | intent = intent.user(event["user_id"]) 239 | 240 | await intent.send_state_event( 241 | self.id, EventType.find(event["type"]), state_key=event["state_key"], content=event["content"] 242 | ) 243 | else: 244 | # invite puppet *now* if we are lazy loading and it should be here 245 | if ( 246 | self.lazy_members is not None 247 | and event["user_id"] in self.lazy_members 248 | and event["user_id"] not in self.members 249 | ): 250 | await self.serv.ensure_irc_user_id(self.network.name, self.lazy_members[event["user_id"]]) 251 | await self._join(event["user_id"], self.lazy_members[event["user_id"]]) 252 | 253 | # if we get an event from unknown user (outside room for some reason) we may have a fallback 254 | if event["user_id"] is not None and event["user_id"] not in self.members: 255 | if "fallback_html" in event and event["fallback_html"] is not None: 256 | fallback_html = event["fallback_html"] 257 | else: 258 | fallback_html = ( 259 | f"{event['user_id']} sent {event['type']} but is not in the room, this is a bug." 260 | ) 261 | 262 | # create fallback event 263 | event["content"] = { 264 | "msgtype": "m.notice", 265 | "body": re.sub("<[^<]+?>", "", event["fallback_html"]), 266 | "format": "org.matrix.custom.html", 267 | "formatted_body": fallback_html, 268 | } 269 | 270 | # unpuppet 271 | event["user_id"] = None 272 | 273 | intent = self.az.intent.user(event["user_id"]) if event["user_id"] else self.az.intent 274 | type = EventType.find(event["type"]) 275 | await intent.send_message_event(self.id, type, event["content"]) 276 | except Exception: 277 | logging.exception("Queued event failed") 278 | 279 | # send message to mx user (may be puppeted) 280 | def send_message( 281 | self, text: str, user_id: Optional[str] = None, formatted=None, fallback_html: Optional[str] = None 282 | ) -> None: 283 | if formatted: 284 | event = { 285 | "type": "m.room.message", 286 | "content": { 287 | "msgtype": "m.text", 288 | "format": "org.matrix.custom.html", 289 | "body": text, 290 | "formatted_body": formatted, 291 | }, 292 | "user_id": user_id, 293 | "fallback_html": fallback_html, 294 | } 295 | else: 296 | event = { 297 | "type": "m.room.message", 298 | "content": { 299 | "msgtype": "m.text", 300 | "body": text, 301 | }, 302 | "user_id": user_id, 303 | "fallback_html": fallback_html, 304 | } 305 | 306 | self._queue.enqueue(event) 307 | 308 | # send emote to mx user (may be puppeted) 309 | def send_emote(self, text: str, user_id: Optional[str] = None, fallback_html: Optional[str] = None) -> None: 310 | event = { 311 | "type": "m.room.message", 312 | "content": { 313 | "msgtype": "m.emote", 314 | "body": text, 315 | }, 316 | "user_id": user_id, 317 | "fallback_html": fallback_html, 318 | } 319 | 320 | self._queue.enqueue(event) 321 | 322 | # send notice to mx user (may be puppeted) 323 | def send_notice( 324 | self, text: str, user_id: Optional[str] = None, formatted=None, fallback_html: Optional[str] = None 325 | ) -> None: 326 | if formatted: 327 | event = { 328 | "type": "m.room.message", 329 | "content": { 330 | "msgtype": "m.notice", 331 | "format": "org.matrix.custom.html", 332 | "body": text, 333 | "formatted_body": formatted, 334 | }, 335 | "user_id": user_id, 336 | "fallback_html": fallback_html, 337 | } 338 | else: 339 | event = { 340 | "type": "m.room.message", 341 | "content": { 342 | "msgtype": "m.notice", 343 | "body": text, 344 | }, 345 | "user_id": user_id, 346 | "fallback_html": fallback_html, 347 | } 348 | 349 | self._queue.enqueue(event) 350 | 351 | # send notice to mx user (may be puppeted) 352 | def send_notice_html(self, text: str, user_id: Optional[str] = None) -> None: 353 | event = { 354 | "type": "m.room.message", 355 | "content": { 356 | "msgtype": "m.notice", 357 | "body": re.sub("<[^<]+?>", "", text), 358 | "format": "org.matrix.custom.html", 359 | "formatted_body": text, 360 | }, 361 | "user_id": user_id, 362 | } 363 | 364 | self._queue.enqueue(event) 365 | 366 | def react(self, event_id: str, text: str) -> None: 367 | event = { 368 | "type": "m.reaction", 369 | "content": { 370 | "m.relates_to": { 371 | "event_id": event_id, 372 | "key": text, 373 | "rel_type": "m.annotation", 374 | } 375 | }, 376 | "user_id": None, 377 | } 378 | 379 | self._queue.enqueue(event) 380 | 381 | def set_topic(self, topic: str, user_id: Optional[str] = None) -> None: 382 | event = { 383 | "type": "m.room.topic", 384 | "content": { 385 | "topic": topic, 386 | }, 387 | "state_key": "", 388 | "user_id": user_id, 389 | } 390 | 391 | self._queue.enqueue(event) 392 | 393 | def join(self, user_id: str, nick=None, lazy=False) -> None: 394 | event = { 395 | "type": "_join", 396 | "content": {}, 397 | "user_id": user_id, 398 | "nick": nick, 399 | "lazy": lazy, 400 | } 401 | 402 | self._queue.enqueue(event) 403 | 404 | def leave(self, user_id: str, reason: Optional[str] = None) -> None: 405 | event = { 406 | "type": "_leave", 407 | "content": {}, 408 | "reason": reason, 409 | "user_id": user_id, 410 | } 411 | 412 | self._queue.enqueue(event) 413 | 414 | def rename(self, old_nick: str, new_nick: str) -> None: 415 | event = { 416 | "type": "_rename", 417 | "content": {}, 418 | "old_nick": old_nick, 419 | "new_nick": new_nick, 420 | } 421 | 422 | self._queue.enqueue(event) 423 | 424 | def kick(self, user_id: str, reason: str) -> None: 425 | event = { 426 | "type": "_kick", 427 | "content": {}, 428 | "reason": reason, 429 | "user_id": user_id, 430 | } 431 | 432 | self._queue.enqueue(event) 433 | 434 | def ensure_irc_user_id(self, network, nick): 435 | event = { 436 | "type": "_ensure_irc_user_id", 437 | "content": {}, 438 | "network": network, 439 | "nick": nick, 440 | "user_id": None, 441 | } 442 | 443 | self._queue.enqueue(event) 444 | -------------------------------------------------------------------------------- /heisenbridge/channel_room.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import html 3 | import logging 4 | from typing import Dict 5 | from typing import List 6 | from typing import Optional 7 | 8 | from irc.modes import parse_channel_modes 9 | 10 | from heisenbridge.command_parse import CommandParser 11 | from heisenbridge.private_room import parse_irc_formatting 12 | from heisenbridge.private_room import PrivateRoom 13 | from heisenbridge.private_room import unix_to_local 14 | 15 | 16 | class NetworkRoom: 17 | pass 18 | 19 | 20 | class ChannelRoom(PrivateRoom): 21 | key: Optional[str] 22 | member_sync: str 23 | autocmd: str 24 | names_buffer: List[str] 25 | bans_buffer: List[str] 26 | on_channel: List[str] 27 | 28 | def init(self) -> None: 29 | super().init() 30 | 31 | self.key = None 32 | self.autocmd = None 33 | 34 | # for migration the class default is full 35 | self.member_sync = "full" 36 | 37 | cmd = CommandParser( 38 | prog="AUTOCMD", 39 | description="run commands on join", 40 | epilog=( 41 | "Works _exactly_ like network AUTOCMD and runs in the network context." 42 | " You can use this to login to bots or other services after joining a channel." 43 | ), 44 | ) 45 | cmd.add_argument("command", nargs="*", help="commands separated with ';'") 46 | cmd.add_argument("--remove", action="store_true", help="remove stored command") 47 | self.commands.register(cmd, self.cmd_autocmd) 48 | 49 | cmd = CommandParser( 50 | prog="SYNC", 51 | description="override IRC member sync type for this room", 52 | epilog="Note: To force full sync after setting to full, use the NAMES command", 53 | ) 54 | group = cmd.add_mutually_exclusive_group() 55 | group.add_argument("--lazy", help="set lazy sync, members are added when they talk", action="store_true") 56 | group.add_argument( 57 | "--half", help="set half sync, members are added when they join or talk", action="store_true" 58 | ) 59 | group.add_argument("--full", help="set full sync, members are fully synchronized", action="store_true") 60 | group.add_argument( 61 | "--off", 62 | help="disable member sync completely, the bridge will relay all messages, may be useful during spam attacks", 63 | action="store_true", 64 | ) 65 | self.commands.register(cmd, self.cmd_sync) 66 | 67 | cmd = CommandParser( 68 | prog="MODE", 69 | description="send MODE command", 70 | epilog=( 71 | "Can be used to change channel modes, ban lists or invoke/manage custom lists.\n" 72 | "It is very network specific what modes or lists are supported, please see their documentation" 73 | " for comprehensive help.\n" 74 | "\n" 75 | "Note: Some common modes and lists may have a command, see HELP.\n" 76 | ), 77 | ) 78 | cmd.add_argument("args", nargs="*", help="MODE command arguments") 79 | self.commands.register(cmd, self.cmd_mode) 80 | 81 | cmd = CommandParser( 82 | prog="NAMES", 83 | description="list channel members", 84 | epilog=( 85 | "Sends a NAMES command to server.\n" 86 | "\n" 87 | "This can be used to see what IRC permissions users currently have on this channel.\n" 88 | "\n" 89 | "Note: In addition this will resynchronize the Matrix room members list and may cause joins/leaves" 90 | " if it has fallen out of sync.\n" 91 | ), 92 | ) 93 | self.commands.register(cmd, self.cmd_names) 94 | 95 | # plumbs have a slightly adjusted version 96 | if type(self) == ChannelRoom: 97 | cmd = CommandParser(prog="TOPIC", description="show or set channel topic") 98 | cmd.add_argument("text", nargs="*", help="topic text if setting") 99 | self.commands.register(cmd, self.cmd_topic) 100 | 101 | cmd = CommandParser(prog="BANS", description="show channel ban list") 102 | self.commands.register(cmd, self.cmd_bans) 103 | 104 | cmd = CommandParser(prog="OP", description="op someone") 105 | cmd.add_argument("nick", help="nick to target") 106 | self.commands.register(cmd, self.cmd_op) 107 | 108 | cmd = CommandParser(prog="DEOP", description="deop someone") 109 | cmd.add_argument("nick", help="nick to target") 110 | self.commands.register(cmd, self.cmd_deop) 111 | 112 | cmd = CommandParser(prog="VOICE", description="voice someone") 113 | cmd.add_argument("nick", help="nick to target") 114 | self.commands.register(cmd, self.cmd_voice) 115 | 116 | cmd = CommandParser(prog="DEVOICE", description="devoice someone") 117 | cmd.add_argument("nick", help="nick to target") 118 | self.commands.register(cmd, self.cmd_devoice) 119 | 120 | cmd = CommandParser(prog="KICK", description="kick someone") 121 | cmd.add_argument("nick", help="nick to target") 122 | cmd.add_argument("reason", nargs="*", help="reason") 123 | self.commands.register(cmd, self.cmd_kick) 124 | 125 | cmd = CommandParser(prog="KB", description="kick and ban someone") 126 | cmd.add_argument("nick", help="nick to target") 127 | cmd.add_argument("reason", nargs="*", help="reason") 128 | self.commands.register(cmd, self.cmd_kb) 129 | 130 | cmd = CommandParser(prog="JOIN", description="join this channel if not on it") 131 | self.commands.register(cmd, self.cmd_join) 132 | 133 | cmd = CommandParser(prog="PART", description="leave this channel temporarily") 134 | self.commands.register(cmd, self.cmd_part) 135 | 136 | cmd = CommandParser( 137 | prog="STOP", 138 | description="immediately clear all queued IRC events like long messages", 139 | epilog="Use this to stop accidental long pastes, also known as STAHP!", 140 | ) 141 | self.commands.register(cmd, self.cmd_stop, ["STOP!", "STAHP", "STAHP!"]) 142 | 143 | cmd = CommandParser( 144 | prog="UPGRADE", 145 | description="Perform any potential bridge-side upgrades of the room", 146 | ) 147 | cmd.add_argument("--undo", action="store_true", help="undo previously performed upgrade") 148 | self.commands.register(cmd, self.cmd_upgrade) 149 | 150 | self.names_buffer = [] 151 | self.bans_buffer = [] 152 | self.on_channel = [] 153 | 154 | def from_config(self, config: dict) -> None: 155 | super().from_config(config) 156 | 157 | if "key" in config: 158 | self.key = config["key"] 159 | 160 | if "member_sync" in config: 161 | self.member_sync = config["member_sync"] 162 | 163 | if "autocmd" in config: 164 | self.autocmd = config["autocmd"] 165 | 166 | # initialize lazy members dict if sync is not off 167 | if self.member_sync != "off": 168 | if self.lazy_members is None: 169 | self.lazy_members = {} 170 | else: 171 | self.lazy_members = None 172 | 173 | def to_config(self) -> dict: 174 | return {**(super().to_config()), "key": self.key, "member_sync": self.member_sync, "autocmd": self.autocmd} 175 | 176 | @staticmethod 177 | def create(network: NetworkRoom, name: str) -> "ChannelRoom": 178 | logging.debug(f"ChannelRoom.create(network='{network.name}', name='{name}'") 179 | 180 | room = ChannelRoom(None, network.user_id, network.serv, [network.user_id, network.serv.user_id], []) 181 | room.name = name.lower() 182 | room.network = network 183 | room.network_id = network.id 184 | room.network_name = network.name 185 | 186 | # fetch stored channel key if used for join command 187 | if room.name in network.keys: 188 | room.key = network.keys[room.name] 189 | del network.keys[room.name] 190 | 191 | # stamp global member sync setting at room creation time 192 | room.member_sync = network.serv.config["member_sync"] 193 | 194 | room.max_lines = network.serv.config["max_lines"] 195 | room.use_pastebin = network.serv.config["use_pastebin"] 196 | room.use_reacts = network.serv.config["use_reacts"] 197 | 198 | asyncio.ensure_future(room._create_mx(name)) 199 | return room 200 | 201 | async def _create_mx(self, name): 202 | # handle !room names properly 203 | visible_name = name 204 | if visible_name.startswith("!"): 205 | visible_name = "!" + visible_name[6:] 206 | 207 | if self.serv.hidden_room: 208 | self.hidden_room_id = self.serv.hidden_room.id 209 | 210 | self.id = await self.network.serv.create_room( 211 | f"{visible_name} ({self.network.name})", 212 | "", 213 | [self.network.user_id], 214 | self.hidden_room_id, 215 | ) 216 | self.serv.register_room(self) 217 | await self.save() 218 | # start event queue now that we have an id 219 | self._queue.start() 220 | 221 | # attach to network space 222 | if self.network.space: 223 | await self.network.space.attach(self.id) 224 | 225 | def is_valid(self) -> bool: 226 | if not self.in_room(self.user_id): 227 | return False 228 | 229 | return super().is_valid() 230 | 231 | def cleanup(self) -> None: 232 | if self.network: 233 | if self.network.conn and self.network.conn.connected: 234 | self.network.conn.part(self.name) 235 | 236 | super().cleanup() 237 | 238 | async def cmd_autocmd(self, args) -> None: 239 | autocmd = " ".join(args.command) 240 | 241 | if args.remove: 242 | self.autocmd = None 243 | await self.save() 244 | self.send_notice("Autocmd removed.", forward=args._forward) 245 | return 246 | 247 | if autocmd == "": 248 | self.send_notice(f"Configured autocmd: {self.autocmd if self.autocmd else ''}", forward=args._forward) 249 | return 250 | 251 | self.autocmd = autocmd 252 | await self.save() 253 | self.send_notice(f"Autocmd set to {self.autocmd}", forward=args._forward) 254 | 255 | async def cmd_sync(self, args): 256 | if args.lazy: 257 | self.member_sync = "lazy" 258 | await self.save() 259 | elif args.half: 260 | self.member_sync = "half" 261 | await self.save() 262 | elif args.full: 263 | self.member_sync = "full" 264 | await self.save() 265 | elif args.off: 266 | self.member_sync = "off" 267 | # prevent anyone already in lazy list to be invited 268 | self.lazy_members = None 269 | await self.save() 270 | 271 | self.send_notice(f"Member sync is set to {self.member_sync}", forward=args._forward) 272 | 273 | async def cmd_mode(self, args) -> None: 274 | self.network.conn.mode(self.name, " ".join(args.args)) 275 | 276 | async def cmd_modes(self, args) -> None: 277 | self.network.conn.mode(self.name, "") 278 | 279 | async def cmd_names(self, args) -> None: 280 | self.network.conn.names(self.name) 281 | 282 | async def cmd_bans(self, args) -> None: 283 | self.network.conn.mode(self.name, "+b") 284 | 285 | async def cmd_op(self, args) -> None: 286 | self.network.conn.mode(self.name, f"+o {args.nick}") 287 | 288 | async def cmd_deop(self, args) -> None: 289 | self.network.conn.mode(self.name, f"-o {args.nick}") 290 | 291 | async def cmd_voice(self, args) -> None: 292 | self.network.conn.mode(self.name, f"+v {args.nick}") 293 | 294 | async def cmd_devoice(self, args) -> None: 295 | self.network.conn.mode(self.name, f"-v {args.nick}") 296 | 297 | async def cmd_topic(self, args) -> None: 298 | self.network.conn.topic(self.name, " ".join(args.text)) 299 | 300 | async def cmd_kick(self, args) -> None: 301 | self.network.conn.kick(self.name, args.nick, " ".join(args.reason)) 302 | 303 | async def cmd_kb(self, args) -> None: 304 | self.network.kickban(self.name, args.nick, " ".join(args.reason)) 305 | 306 | async def cmd_join(self, args) -> None: 307 | self.network.conn.join(self.name, self.key) 308 | 309 | async def cmd_part(self, args) -> None: 310 | self.network.conn.part(self.name) 311 | 312 | async def cmd_stop(self, args) -> None: 313 | filtered = self.network.conn.remove_tag(self.name) 314 | self.send_notice(f"{filtered} messages removed from queue.") 315 | 316 | def on_pubmsg(self, conn, event): 317 | self.on_privmsg(conn, event) 318 | 319 | def on_pubnotice(self, conn, event): 320 | self.on_privnotice(conn, event) 321 | 322 | def on_namreply(self, conn, event) -> None: 323 | self.names_buffer.extend(event.arguments[2].split()) 324 | 325 | def _add_puppet(self, nick): 326 | irc_user_id = self.serv.irc_user_id(self.network.name, nick) 327 | 328 | self.ensure_irc_user_id(self.network.name, nick) 329 | self.join(irc_user_id, nick) 330 | 331 | def _remove_puppet(self, user_id, reason=None): 332 | if user_id == self.serv.user_id or user_id == self.user_id: 333 | return 334 | 335 | self.leave(user_id, reason) 336 | 337 | def on_endofnames(self, conn, event) -> None: 338 | to_remove = [] 339 | to_add = [] 340 | names = list(self.names_buffer) 341 | self.names_buffer = [] 342 | modes: Dict[str, List[str]] = {} 343 | others = [] 344 | on_channel = [] 345 | 346 | # always reset lazy list because it can be toggled on-the-fly 347 | self.lazy_members = {} if self.member_sync != "off" else None 348 | 349 | # build to_remove list from our own puppets 350 | for member in self.members: 351 | (name, server) = member.split(":", 1) 352 | 353 | if name.startswith("@" + self.serv.puppet_prefix) and server == self.serv.server_name: 354 | to_remove.append(member) 355 | 356 | for nick in names: 357 | nick, mode = self.serv.strip_nick(nick) 358 | on_channel.append(nick.lower()) 359 | 360 | if mode: 361 | if mode not in modes: 362 | modes[mode] = [] 363 | 364 | if nick == conn.real_nickname: 365 | modes[mode].append(nick + " (you)") 366 | else: 367 | modes[mode].append(nick) 368 | else: 369 | if nick == conn.real_nickname: 370 | others.append(nick + " (you)") 371 | else: 372 | others.append(nick) 373 | 374 | # convert to mx id, check if we already have them 375 | irc_user_id = self.serv.irc_user_id(self.network.name, nick) 376 | 377 | # make sure this user is not removed from room 378 | if irc_user_id in to_remove: 379 | to_remove.remove(irc_user_id) 380 | continue 381 | 382 | # ignore adding us here, only lazy join on echo allowed 383 | if nick == conn.real_nickname: 384 | continue 385 | 386 | # if this user is not in room, add to invite list 387 | if not self.in_room(irc_user_id): 388 | to_add.append((irc_user_id, nick)) 389 | 390 | # always put everyone in the room to lazy list if we have any member sync 391 | if self.lazy_members is not None: 392 | self.lazy_members[irc_user_id] = nick 393 | 394 | # never remove us or appservice 395 | if self.serv.user_id in to_remove: 396 | to_remove.remove(self.serv.user_id) 397 | if self.user_id in to_remove: 398 | to_remove.remove(self.user_id) 399 | 400 | self.send_notice( 401 | "Synchronizing members:" 402 | + f" got {len(names)} from server," 403 | + f" {len(self.members)} in room," 404 | + f" {len(to_add)} will be invited and {len(to_remove)} removed." 405 | ) 406 | 407 | # known common mode names 408 | modenames = { 409 | "~": "owner", 410 | "&": "admin", 411 | "@": "op", 412 | "%": "half-op", 413 | "+": "voice", 414 | } 415 | 416 | # show modes from top to bottom 417 | for mode, name in modenames.items(): 418 | if mode in modes: 419 | nicks = sorted(modes[mode], key=str.casefold) 420 | self.send_notice(f"Users with {name} ({mode}): {', '.join(nicks)}") 421 | del modes[mode] 422 | 423 | # show unknown modes 424 | for mode, nicks in modes.items(): 425 | nicks = sorted(nicks, key=str.casefold) 426 | self.send_notice(f"Users with '{mode}': {', '.join(nicks)}") 427 | 428 | # show everyone else 429 | if len(others) > 0: 430 | others = sorted(others, key=str.casefold) 431 | self.send_notice(f"Users: {', '.join(others)}") 432 | 433 | if self.member_sync == "full": 434 | for irc_user_id, nick in to_add: 435 | self._add_puppet(nick) 436 | else: 437 | self.send_notice(f"Member sync is set to {self.member_sync}, skipping invites.") 438 | 439 | for irc_user_id in to_remove: 440 | self._remove_puppet(irc_user_id) 441 | 442 | # trust the names reply is always up-to-date 443 | self.on_channel = on_channel 444 | 445 | def is_on_channel(self, nick): 446 | return nick.lower() in self.on_channel 447 | 448 | def channel_join(self, nick): 449 | nick = nick.lower() 450 | if nick not in self.on_channel: 451 | self.on_channel.append(nick) 452 | 453 | def channel_leave(self, nick): 454 | nick = nick.lower() 455 | if nick in self.on_channel: 456 | self.on_channel.remove(nick) 457 | 458 | def on_join(self, conn, event) -> None: 459 | self.channel_join(event.source.nick) 460 | 461 | # we don't need to sync ourself 462 | if conn.real_nickname == event.source.nick: 463 | self.send_notice(f"Joined {event.target} as {event.source.nick} ({event.source.userhost})") 464 | 465 | # sync channel modes/key on join 466 | self.network.conn.mode(self.name, "") 467 | 468 | # send autocmd if we have one 469 | if self.autocmd: 470 | 471 | async def autocmd(self): 472 | self.send_notice("Executing channel autocmd.") 473 | try: 474 | await self.network.commands.trigger( 475 | self.autocmd, allowed=["RAW", "MSG", "NICKSERV", "NS", "CHANSERV", "CS", "WAIT"] 476 | ) 477 | except Exception as e: 478 | self.send_notice(f"Channel autocmd failed: {str(e)}") 479 | 480 | asyncio.ensure_future(autocmd(self)) 481 | 482 | return 483 | 484 | # ensure, append, invite and join 485 | if self.member_sync == "full" or self.member_sync == "half": 486 | self._add_puppet(event.source.nick) 487 | elif self.member_sync != "off": 488 | irc_user_id = self.serv.irc_user_id(self.network.name, event.source.nick) 489 | self.join(irc_user_id, event.source.nick, lazy=True) 490 | 491 | def on_part(self, conn, event) -> None: 492 | self.channel_leave(event.source.nick) 493 | 494 | # we don't need to sync ourself 495 | if conn.real_nickname == event.source.nick: 496 | # immediately dequeue all future events 497 | conn.remove_tag(event.target.lower()) 498 | 499 | self.send_notice_html( 500 | f"You left the channel. To rejoin, type JOIN {event.target} in the {self.network.name} network room." 501 | ) 502 | self.send_notice_html("If you want to permanently leave you need to leave this room.") 503 | return 504 | 505 | irc_user_id = self.serv.irc_user_id(self.network.name, event.source.nick) 506 | self._remove_puppet(irc_user_id, event.arguments[0] if len(event.arguments) else None) 507 | 508 | def on_quit(self, conn, event) -> None: 509 | self.channel_leave(event.source.nick) 510 | irc_user_id = self.serv.irc_user_id(self.network.name, event.source.nick) 511 | self._remove_puppet(irc_user_id, f"Quit: {event.arguments[0]}") 512 | 513 | def update_key(self, modes): 514 | for sign, key, value in parse_channel_modes(" ".join(modes)): 515 | # update channel key 516 | if key == "k": 517 | value = None if sign == "-" else value 518 | if value != self.key: 519 | self.key = value 520 | if self.id is not None: 521 | asyncio.ensure_future(self.save()) 522 | 523 | def on_badchannelkey(self, conn, event) -> None: 524 | self.send_notice(event.arguments[1] if len(event.arguments) > 1 else "Incorrect channel key, join failed.") 525 | self.send_notice_html( 526 | f"Use JOIN {html.escape(event.arguments[0])} <key> in the network room to rejoin this channel." 527 | ) 528 | 529 | def on_chanoprivsneeded(self, conn, event) -> None: 530 | self.send_notice(event.arguments[1] if len(event.arguments) > 1 else "You're not operator.") 531 | 532 | def on_cannotsendtochan(self, conn, event) -> None: 533 | self.send_notice(event.arguments[1] if len(event.arguments) > 1 else "Cannot send to channel.") 534 | 535 | def on_mode(self, conn, event) -> None: 536 | modes = list(event.arguments) 537 | 538 | self.send_notice("{} set modes {}".format(event.source.nick, " ".join(modes))) 539 | self.update_key(modes) 540 | 541 | def on_notopic(self, conn, event) -> None: 542 | self.send_notice(event.arguments[1] if len(event.arguments) > 1 else "No topic is set.") 543 | self.set_topic("") 544 | 545 | def on_currenttopic(self, conn, event) -> None: 546 | (plain, formatted) = parse_irc_formatting(event.arguments[1]) 547 | self.send_notice(f"Topic is '{plain}'") 548 | self.set_topic(plain) 549 | 550 | def on_topicinfo(self, conn, event) -> None: 551 | settime = unix_to_local(event.arguments[2]) if len(event.arguments) > 2 else "?" 552 | (plain, formatted) = parse_irc_formatting(event.arguments[1]) 553 | self.send_notice(f"Topic set by {plain} at {settime}") 554 | 555 | def on_topic(self, conn, event) -> None: 556 | self.send_notice("{} changed the topic".format(event.source.nick)) 557 | (plain, formatted) = parse_irc_formatting(event.arguments[0]) 558 | self.set_topic(plain) 559 | 560 | def on_kick(self, conn, event) -> None: 561 | self.channel_leave(event.arguments[0]) 562 | 563 | reason = (": " + event.arguments[1]) if len(event.arguments) > 1 and len(event.arguments[1]) > 0 else "" 564 | 565 | if event.arguments[0] == conn.real_nickname: 566 | # immediately dequeue all future events 567 | conn.remove_tag(event.target.lower()) 568 | 569 | self.send_notice_html(f"You were kicked from the channel by {event.source.nick}{reason}") 570 | if self.network.rejoin_kick: 571 | self.send_notice("Rejoin on kick is enabled, trying to join back immediately...") 572 | conn.join(event.target) 573 | else: 574 | self.send_notice_html( 575 | f"To rejoin the channel, type JOIN {event.target} in the {self.network.name} network room." 576 | ) 577 | else: 578 | target_user_id = self.serv.irc_user_id(self.network.name, event.arguments[0]) 579 | self.kick(target_user_id, f"Kicked by {event.source.nick}{reason}") 580 | 581 | def on_banlist(self, conn, event) -> None: 582 | parts = list(event.arguments) 583 | parts.pop(0) 584 | self.bans_buffer.append(parts) 585 | 586 | def on_endofbanlist(self, conn, event) -> None: 587 | bans = self.bans_buffer 588 | self.bans_buffer = [] 589 | 590 | self.send_notice("Current channel bans:") 591 | for ban in bans: 592 | strban = f"\t{ban[0]}" 593 | 594 | # all other argumenta are optional 595 | if len(ban) > 1: 596 | strban += f" set by {ban[1]}" 597 | if len(ban) > 2: 598 | strban += f" at {unix_to_local(ban[2])}" 599 | 600 | self.send_notice(strban) 601 | 602 | def on_channelmodeis(self, conn, event) -> None: 603 | modes = list(event.arguments) 604 | modes.pop(0) 605 | 606 | self.send_notice(f"Current channel modes: {' '.join(modes)}") 607 | 608 | def on_channelcreate(self, conn, event) -> None: 609 | created = unix_to_local(event.arguments[1]) 610 | self.send_notice(f"Channel was created at {created}") 611 | 612 | def on_328(self, conn, event) -> None: 613 | (plain, formatted) = parse_irc_formatting(event.arguments[1]) 614 | self.send_notice(f"URL for {event.arguments[0]}: {plain}") 615 | -------------------------------------------------------------------------------- /heisenbridge/control_room.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | from argparse import Namespace 4 | from html import escape 5 | from urllib.parse import urlparse 6 | 7 | from mautrix.errors import MatrixRequestError 8 | 9 | from heisenbridge import __version__ 10 | from heisenbridge.command_parse import CommandManager 11 | from heisenbridge.command_parse import CommandParser 12 | from heisenbridge.command_parse import CommandParserError 13 | from heisenbridge.network_room import NetworkRoom 14 | from heisenbridge.room import Room 15 | from heisenbridge.room import RoomInvalidError 16 | 17 | 18 | def indent(n): 19 | return " " * n * 8 20 | 21 | 22 | class ControlRoom(Room): 23 | commands: CommandManager 24 | 25 | def init(self): 26 | self.commands = CommandManager() 27 | 28 | cmd = CommandParser(prog="NETWORKS", description="list available networks") 29 | self.commands.register(cmd, self.cmd_networks) 30 | 31 | cmd = CommandParser(prog="SERVERS", description="list servers for a network") 32 | cmd.add_argument("network", help="network name (see NETWORKS)") 33 | self.commands.register(cmd, self.cmd_servers) 34 | 35 | cmd = CommandParser(prog="OPEN", description="open network for connecting") 36 | cmd.add_argument("name", help="network name (see NETWORKS)") 37 | cmd.add_argument("--new", action="store_true", help="force open a new network connection") 38 | self.commands.register(cmd, self.cmd_open) 39 | 40 | cmd = CommandParser( 41 | prog="STATUS", 42 | description="show bridge status", 43 | epilog="Note: admins see all users but only their own rooms", 44 | ) 45 | self.commands.register(cmd, self.cmd_status) 46 | 47 | cmd = CommandParser( 48 | prog="QUIT", 49 | description="disconnect from all networks", 50 | epilog=( 51 | "For quickly leaving all networks and removing configurations in a single command.\n" 52 | "\n" 53 | "Additionally this will close current DM session with the bridge.\n" 54 | ), 55 | ) 56 | self.commands.register(cmd, self.cmd_quit) 57 | 58 | if self.serv.is_admin(self.user_id): 59 | cmd = CommandParser(prog="MASKS", description="list allow masks") 60 | self.commands.register(cmd, self.cmd_masks) 61 | 62 | cmd = CommandParser( 63 | prog="HIDDENROOM", 64 | description="Use a hidden room to offload invites into. Keeps room history clean.", 65 | ) 66 | group = cmd.add_mutually_exclusive_group() 67 | group.add_argument("--enable", help="Enable use of hidden room", action="store_true") 68 | group.add_argument("--disable", help="Disable use of hidden room", action="store_true") 69 | self.commands.register(cmd, self.cmd_hidden_room) 70 | 71 | cmd = CommandParser( 72 | prog="ADDMASK", 73 | description="add new allow mask", 74 | epilog=( 75 | "For anyone else than the owner to use this bridge they need to be allowed to talk with the bridge bot.\n" 76 | "This is accomplished by adding an allow mask that determines their permission level when using the bridge.\n" 77 | "\n" 78 | "Only admins can manage networks, normal users can just connect.\n" 79 | ), 80 | ) 81 | cmd.add_argument("mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)") 82 | cmd.add_argument("--admin", help="Admin level access", action="store_true") 83 | self.commands.register(cmd, self.cmd_addmask) 84 | 85 | cmd = CommandParser( 86 | prog="DELMASK", 87 | description="delete allow mask", 88 | epilog=( 89 | "Note: Removing a mask only prevents starting a new DM with the bridge bot. Use FORGET for ending existing" 90 | " sessions." 91 | ), 92 | ) 93 | cmd.add_argument("mask", help="Matrix ID mask (eg: @friend:contoso.com or *:contoso.com)") 94 | self.commands.register(cmd, self.cmd_delmask) 95 | 96 | cmd = CommandParser(prog="ADDNETWORK", description="add new network") 97 | cmd.add_argument("name", help="network name") 98 | self.commands.register(cmd, self.cmd_addnetwork) 99 | 100 | cmd = CommandParser(prog="DELNETWORK", description="delete network") 101 | cmd.add_argument("name", help="network name") 102 | self.commands.register(cmd, self.cmd_delnetwork) 103 | 104 | cmd = CommandParser(prog="ADDSERVER", description="add server to a network") 105 | cmd.add_argument("network", help="network name") 106 | cmd.add_argument("address", help="server address") 107 | cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) 108 | cmd.add_argument("--tls", action="store_true", help="use TLS encryption", default=False) 109 | cmd.add_argument( 110 | "--tls-insecure", 111 | action="store_true", 112 | help="ignore TLS verification errors (hostname, self-signed, expired)", 113 | default=False, 114 | ) 115 | cmd.add_argument( 116 | "--tls-ciphers", 117 | help="set TLS cipher string (in OpenSSL cipher list format)", 118 | default=None, 119 | ) 120 | cmd.add_argument("--proxy", help="use a SOCKS proxy (socks5://...)", default=None) 121 | self.commands.register(cmd, self.cmd_addserver) 122 | 123 | cmd = CommandParser(prog="DELSERVER", description="delete server from a network") 124 | cmd.add_argument("network", help="network name") 125 | cmd.add_argument("address", help="server address") 126 | cmd.add_argument("port", nargs="?", type=int, help="server port", default=6667) 127 | self.commands.register(cmd, self.cmd_delserver) 128 | 129 | cmd = CommandParser( 130 | prog="FORGET", 131 | description="remove all connections and configuration of a user", 132 | epilog=( 133 | "Kills all connections of this user, removes all user set configuration and makes the bridge leave all rooms" 134 | " where this user is in.\n" 135 | "If the user still has an allow mask they can DM the bridge again to reconfigure and reconnect.\n" 136 | "\n" 137 | "This is meant as a way to kick users after removing an allow mask or resetting a user after losing access to" 138 | " existing account/rooms for any reason.\n" 139 | ), 140 | ) 141 | cmd.add_argument("user", help="Matrix ID (eg: @ex-friend:contoso.com)") 142 | self.commands.register(cmd, self.cmd_forget) 143 | 144 | cmd = CommandParser(prog="DISPLAYNAME", description="change bridge displayname") 145 | cmd.add_argument("displayname", help="new bridge displayname") 146 | self.commands.register(cmd, self.cmd_displayname) 147 | 148 | cmd = CommandParser(prog="AVATAR", description="change bridge avatar") 149 | cmd.add_argument("url", help="new avatar URL (mxc:// format)") 150 | self.commands.register(cmd, self.cmd_avatar) 151 | 152 | cmd = CommandParser( 153 | prog="IDENT", 154 | description="configure ident replies", 155 | epilog="Note: MXID here is case sensitive, see subcommand help with IDENTCFG SET -h", 156 | ) 157 | subcmd = cmd.add_subparsers(help="commands", dest="cmd") 158 | subcmd.add_parser("list", help="list custom idents (default)") 159 | cmd_set = subcmd.add_parser("set", help="set custom ident") 160 | cmd_set.add_argument("mxid", help="mxid of the user") 161 | cmd_set.add_argument("ident", help="custom ident for the user") 162 | cmd_remove = subcmd.add_parser("remove", help="remove custom ident") 163 | cmd_remove.add_argument("mxid", help="mxid of the user") 164 | self.commands.register(cmd, self.cmd_ident) 165 | 166 | cmd = CommandParser( 167 | prog="SYNC", 168 | description="set default IRC member sync mode", 169 | epilog="Note: Users can override this per room.", 170 | ) 171 | group = cmd.add_mutually_exclusive_group() 172 | group.add_argument("--lazy", help="set lazy sync, members are added when they talk", action="store_true") 173 | group.add_argument( 174 | "--half", help="set half sync, members are added when they join or talk (default)", action="store_true" 175 | ) 176 | group.add_argument("--full", help="set full sync, members are fully synchronized", action="store_true") 177 | self.commands.register(cmd, self.cmd_sync) 178 | 179 | cmd = CommandParser( 180 | prog="MAXLINES", 181 | description="set default maximum number of lines per message until truncation or pastebin", 182 | epilog="Note: Users can override this per room.", 183 | ) 184 | cmd.add_argument("lines", type=int, nargs="?", help="Number of lines") 185 | self.commands.register(cmd, self.cmd_maxlines) 186 | 187 | cmd = CommandParser( 188 | prog="PASTEBIN", 189 | description="enable or disable automatic pastebin of long messages by default", 190 | epilog="Note: Users can override this per room.", 191 | ) 192 | cmd.add_argument("--enable", dest="enabled", action="store_true", help="Enable pastebin") 193 | cmd.add_argument( 194 | "--disable", dest="enabled", action="store_false", help="Disable pastebin (messages will be truncated)" 195 | ) 196 | cmd.set_defaults(enabled=None) 197 | self.commands.register(cmd, self.cmd_pastebin) 198 | 199 | cmd = CommandParser( 200 | prog="REACTS", 201 | description="enable or disable reacting to messages on splits/linking", 202 | epilog="Note: Users can override this per room.", 203 | ) 204 | cmd.add_argument("--enable", dest="enabled", action="store_true", help="Enable reacts") 205 | cmd.add_argument("--disable", dest="enabled", action="store_false", help="Disable reacts") 206 | cmd.set_defaults(enabled=None) 207 | self.commands.register(cmd, self.cmd_reacts) 208 | 209 | cmd = CommandParser(prog="MEDIAURL", description="configure media URL for links") 210 | cmd.add_argument("url", nargs="?", help="new URL override") 211 | cmd.add_argument("--remove", help="remove URL override (will retry auto-detection)", action="store_true") 212 | self.commands.register(cmd, self.cmd_media_url) 213 | 214 | cmd = CommandParser(prog="MEDIAPATH", description="configure media path for links") 215 | cmd.add_argument("path", nargs="?", help="new path override") 216 | cmd.add_argument("--remove", help="remove path override", action="store_true") 217 | self.commands.register(cmd, self.cmd_media_path) 218 | 219 | cmd = CommandParser(prog="VERSION", description="show bridge version") 220 | self.commands.register(cmd, self.cmd_version) 221 | 222 | self.mx_register("m.room.message", self.on_mx_message) 223 | 224 | def is_valid(self) -> bool: 225 | if self.user_id is None: 226 | return False 227 | 228 | if len(self.members) != 2: 229 | return False 230 | 231 | return True 232 | 233 | async def show_help(self): 234 | self.send_notice_html( 235 | f"Howdy, stranger! You have been granted access to the IRC bridge of {self.serv.server_name}." 236 | ) 237 | 238 | try: 239 | return await self.commands.trigger("HELP") 240 | except CommandParserError as e: 241 | return self.send_notice(str(e)) 242 | 243 | async def on_mx_message(self, event) -> bool: 244 | if str(event.content.msgtype) != "m.text" or event.sender == self.serv.user_id: 245 | return 246 | 247 | # ignore edits 248 | if event.content.get_edit(): 249 | return 250 | 251 | try: 252 | if event.content.formatted_body: 253 | lines = str(await self.parser.parse(event.content.formatted_body)).split("\n") 254 | else: 255 | lines = event.content.body.split("\n") 256 | 257 | command = lines.pop(0) 258 | tail = "\n".join(lines) if len(lines) > 0 else None 259 | 260 | await self.commands.trigger(command, tail) 261 | except CommandParserError as e: 262 | self.send_notice(str(e)) 263 | 264 | def networks(self): 265 | networks = {} 266 | 267 | for network, config in self.serv.config["networks"].items(): 268 | config["name"] = network 269 | networks[network.lower()] = config 270 | 271 | return networks 272 | 273 | async def cmd_hidden_room(self, args): 274 | if args.enable: 275 | self.serv.config["use_hidden_room"] = True 276 | await self.serv.save() 277 | elif args.disable: 278 | self.serv.config["use_hidden_room"] = False 279 | await self.serv.save() 280 | 281 | try: 282 | is_enabled = await self.serv.ensure_hidden_room() 283 | self.send_notice(f"Hidden room is {'enabled' if is_enabled else 'disabled'}.") 284 | except Exception as e: 285 | self.send_notice(f"Failed setting up hidden room: {e}") 286 | 287 | async def cmd_masks(self, args): 288 | msg = "Configured masks:\n" 289 | 290 | for mask, value in self.serv.config["allow"].items(): 291 | msg += "\t{} -> {}\n".format(mask, value) 292 | 293 | self.send_notice(msg) 294 | 295 | async def cmd_addmask(self, args): 296 | masks = self.serv.config["allow"] 297 | 298 | if args.mask in masks: 299 | return self.send_notice("Mask already exists") 300 | 301 | masks[args.mask] = "admin" if args.admin else "user" 302 | await self.serv.save() 303 | 304 | self.send_notice("Mask added.") 305 | 306 | async def cmd_delmask(self, args): 307 | masks = self.serv.config["allow"] 308 | 309 | if args.mask not in masks: 310 | return self.send_notice("Mask does not exist") 311 | 312 | del masks[args.mask] 313 | await self.serv.save() 314 | 315 | self.send_notice("Mask removed.") 316 | 317 | async def cmd_networks(self, args): 318 | networks = self.serv.config["networks"] 319 | 320 | self.send_notice("Configured networks:") 321 | 322 | for network, data in networks.items(): 323 | self.send_notice(f"\t{network} ({len(data['servers'])} servers)") 324 | 325 | async def cmd_addnetwork(self, args): 326 | networks = self.networks() 327 | 328 | if args.name.lower() in networks: 329 | return self.send_notice("Network already exists") 330 | 331 | self.serv.config["networks"][args.name] = {"servers": []} 332 | await self.serv.save() 333 | 334 | self.send_notice("Network added.") 335 | 336 | async def cmd_delnetwork(self, args): 337 | networks = self.networks() 338 | 339 | if args.name.lower() not in networks: 340 | return self.send_notice("Network does not exist") 341 | 342 | # FIXME: check if anyone is currently connected 343 | 344 | # FIXME: if no one is currently connected, leave from all network related rooms 345 | 346 | del self.serv.config["networks"][args.name] 347 | await self.serv.save() 348 | 349 | return self.send_notice("Network removed.") 350 | 351 | async def cmd_servers(self, args): 352 | networks = self.networks() 353 | 354 | if args.network.lower() not in networks: 355 | return self.send_notice("Network does not exist") 356 | 357 | network = networks[args.network.lower()] 358 | 359 | self.send_notice(f"Configured servers for {network['name']}:") 360 | 361 | for server in network["servers"]: 362 | with_tls = "" 363 | if server["tls"]: 364 | if "tls_insecure" in server and server["tls_insecure"]: 365 | with_tls = "with insecure TLS" 366 | else: 367 | with_tls = "with TLS" 368 | proxy = ( 369 | f" through {server['proxy']}" 370 | if "proxy" in server and server["proxy"] is not None and len(server["proxy"]) > 0 371 | else "" 372 | ) 373 | self.send_notice(f"\t{server['address']}:{server['port']} {with_tls}{proxy}") 374 | 375 | async def cmd_addserver(self, args): 376 | networks = self.networks() 377 | 378 | if args.network.lower() not in networks: 379 | return self.send_notice("Network does not exist") 380 | 381 | network = networks[args.network.lower()] 382 | address = args.address.lower() 383 | 384 | for server in network["servers"]: 385 | if server["address"] == address and server["port"] == args.port: 386 | return self.send_notice("This server already exists.") 387 | 388 | self.serv.config["networks"][network["name"]]["servers"].append( 389 | { 390 | "address": address, 391 | "port": args.port, 392 | "tls": args.tls, 393 | "tls_insecure": args.tls_insecure, 394 | "tls_ciphers": args.tls_ciphers, 395 | "proxy": args.proxy, 396 | } 397 | ) 398 | await self.serv.save() 399 | 400 | self.send_notice("Server added.") 401 | 402 | async def cmd_delserver(self, args): 403 | networks = self.networks() 404 | 405 | if args.network.lower() not in networks: 406 | return self.send_notice("Network does not exist") 407 | 408 | network = networks[args.network.lower()] 409 | address = args.address.lower() 410 | 411 | to_pop = -1 412 | for i, server in enumerate(network["servers"]): 413 | if server["address"] == address and server["port"] == args.port: 414 | to_pop = i 415 | break 416 | 417 | if to_pop == -1: 418 | return self.send_notice("No such server.") 419 | 420 | self.serv.config["networks"][network["name"]]["servers"].pop(to_pop) 421 | await self.serv.save() 422 | 423 | self.send_notice("Server deleted.") 424 | 425 | async def cmd_status(self, args): 426 | users = set() 427 | 428 | if self.serv.is_admin(self.user_id): 429 | for room in self.serv.find_rooms(): 430 | if room.user_id is not None: # ignore HiddenRoom 431 | users.add(room.user_id) 432 | 433 | users = list(users) 434 | users.sort() 435 | else: 436 | users.add(self.user_id) 437 | 438 | self.send_notice_html(f"I have {len(users)} known users:") 439 | for user_id in users: 440 | ncontrol = len(self.serv.find_rooms("ControlRoom", user_id)) 441 | 442 | self.send_notice_html(f"{indent(1)}{user_id} ({ncontrol} open control rooms):") 443 | 444 | for network in self.serv.find_rooms("NetworkRoom", user_id): 445 | connected = "not connected" 446 | channels = "not in channels" 447 | privates = "not in PMs" 448 | plumbs = "not in plumbs" 449 | 450 | if network.conn and network.conn.connected: 451 | user = network.real_user if network.real_user[0] != "?" else "?" 452 | host = network.real_host if network.real_host[0] != "?" else "?" 453 | connected = f"connected as {network.conn.real_nickname}!{user}@{host}" 454 | 455 | nchannels = 0 456 | nprivates = 0 457 | nplumbs = 0 458 | 459 | for room in network.rooms.values(): 460 | if type(room).__name__ == "PrivateRoom": 461 | nprivates += 1 462 | if type(room).__name__ == "ChannelRoom": 463 | nchannels += 1 464 | if type(room).__name__ == "PlumbedRoom": 465 | nplumbs += 1 466 | 467 | if nprivates > 0: 468 | privates = f"in {nprivates} PMs" 469 | 470 | if nchannels > 0: 471 | channels = f"in {nchannels} channels" 472 | 473 | if nplumbs > 0: 474 | plumbs = f"in {nplumbs} plumbs" 475 | 476 | self.send_notice_html(f"{indent(2)}{network.name}, {connected}, {channels}, {privates}, {plumbs}") 477 | 478 | if self.user_id == user_id: 479 | for room in network.rooms.values(): 480 | join = "" 481 | if not room.in_room(user_id): 482 | join = " (you have not joined this room)" 483 | # ensure the user invite is valid 484 | await self.az.intent.invite_user(room.id, self.user_id) 485 | self.send_notice_html( 486 | f'{indent(3)}{escape(room.name)}{join}' 487 | ) 488 | 489 | async def cmd_forget(self, args): 490 | if args.user == self.user_id: 491 | return self.send_notice("I can't forget you, silly!") 492 | 493 | rooms = self.serv.find_rooms(None, args.user) 494 | 495 | if len(rooms) == 0: 496 | return self.send_notice("No such user. See STATUS for list of users.") 497 | 498 | # disconnect each network room in first pass 499 | for room in rooms: 500 | if type(room) == NetworkRoom and room.conn and room.conn.connected: 501 | self.send_notice(f"Disconnecting {args.user} from {room.name}...") 502 | await room.cmd_disconnect(Namespace()) 503 | 504 | self.send_notice(f"Leaving all {len(rooms)} rooms {args.user} was in...") 505 | 506 | # then just forget everything 507 | for room in rooms: 508 | self.serv.unregister_room(room.id) 509 | 510 | try: 511 | await self.az.intent.leave_room(room.id) 512 | except MatrixRequestError: 513 | pass 514 | try: 515 | await self.az.intent.forget_room(room.id) 516 | except MatrixRequestError: 517 | pass 518 | 519 | self.send_notice(f"Done, I have forgotten about {args.user}") 520 | 521 | async def cmd_displayname(self, args): 522 | try: 523 | await self.az.intent.set_displayname(args.displayname) 524 | except MatrixRequestError as e: 525 | self.send_notice(f"Failed to set displayname: {str(e)}") 526 | 527 | async def cmd_avatar(self, args): 528 | try: 529 | await self.az.intent.set_avatar_url(args.url) 530 | except MatrixRequestError as e: 531 | self.send_notice(f"Failed to set avatar: {str(e)}") 532 | 533 | async def cmd_ident(self, args): 534 | idents = self.serv.config["idents"] 535 | 536 | if args.cmd == "list" or args.cmd is None: 537 | self.send_notice("Configured custom idents:") 538 | for mxid, ident in idents.items(): 539 | self.send_notice(f"\t{mxid} -> {ident}") 540 | elif args.cmd == "set": 541 | if not re.match(r"^[a-z][-a-z0-9]*$", args.ident): 542 | self.send_notice(f"Invalid ident string: {args.ident}") 543 | self.send_notice("Must be lowercase, start with a letter, can contain dashes, letters and numbers.") 544 | else: 545 | idents[args.mxid] = args.ident 546 | self.send_notice(f"Set custom ident for {args.mxid} to {args.ident}") 547 | await self.serv.save() 548 | elif args.cmd == "remove": 549 | if args.mxid in idents: 550 | del idents[args.mxid] 551 | self.send_notice(f"Removed custom ident for {args.mxid}") 552 | await self.serv.save() 553 | else: 554 | self.send_notice(f"No custom ident for {args.mxid}") 555 | 556 | async def cmd_sync(self, args): 557 | if args.lazy: 558 | self.serv.config["member_sync"] = "lazy" 559 | await self.serv.save() 560 | elif args.half: 561 | self.serv.config["member_sync"] = "half" 562 | await self.serv.save() 563 | elif args.full: 564 | self.serv.config["member_sync"] = "full" 565 | await self.serv.save() 566 | 567 | self.send_notice(f"Member sync is set to {self.serv.config['member_sync']}") 568 | 569 | async def cmd_media_url(self, args): 570 | if args.remove: 571 | self.serv.config["media_url"] = None 572 | await self.serv.save() 573 | self.serv.endpoint = await self.serv.detect_public_endpoint() 574 | elif args.url: 575 | parsed = urlparse(args.url) 576 | if parsed.scheme in ["http", "https"] and not parsed.params and not parsed.query and not parsed.fragment: 577 | self.serv.config["media_url"] = args.url 578 | await self.serv.save() 579 | self.serv.endpoint = args.url 580 | else: 581 | self.send_notice(f"Invalid media URL format: {args.url}") 582 | return 583 | 584 | self.send_notice(f"Media URL override is set to {self.serv.config['media_url']}") 585 | self.send_notice(f"Current active media URL: {self.serv.endpoint}") 586 | 587 | async def cmd_media_path(self, args): 588 | if args.remove: 589 | self.serv.config["media_path"] = None 590 | await self.serv.save() 591 | self.serv.media_path = self.serv.DEFAULT_MEDIA_PATH 592 | elif args.path: 593 | self.serv.config["media_path"] = args.path 594 | await self.serv.save() 595 | self.serv.media_path = args.path 596 | 597 | self.send_notice(f"Media Path override is set to {self.serv.config['media_path']}") 598 | self.send_notice(f"Current active media path: {self.serv.media_path}") 599 | 600 | async def cmd_maxlines(self, args): 601 | if args.lines is not None: 602 | self.serv.config["max_lines"] = args.lines 603 | await self.serv.save() 604 | 605 | self.send_notice(f"Max lines default is {self.serv.config['max_lines']}") 606 | 607 | async def cmd_pastebin(self, args): 608 | if args.enabled is not None: 609 | self.serv.config["use_pastebin"] = args.enabled 610 | await self.serv.save() 611 | 612 | self.send_notice(f"Pastebin is {'enabled' if self.serv.config['use_pastebin'] else 'disabled'} by default") 613 | 614 | async def cmd_reacts(self, args): 615 | if args.enabled is not None: 616 | self.serv.config["use_reacts"] = args.enabled 617 | await self.serv.save() 618 | 619 | self.send_notice(f"Reacts are {'enabled' if self.serv.config['use_reacts'] else 'disabled'} by default") 620 | 621 | async def cmd_open(self, args): 622 | networks = self.networks() 623 | name = args.name.lower() 624 | 625 | if name not in networks: 626 | return self.send_notice("Network does not exist") 627 | 628 | network = networks[name] 629 | 630 | found = 0 631 | for room in self.serv.find_rooms(NetworkRoom, self.user_id): 632 | if room.name == network["name"]: 633 | found += 1 634 | 635 | if not args.new: 636 | if self.user_id not in room.members: 637 | self.send_notice(f"Inviting back to {room.name} ({room.id})") 638 | await self.az.intent.invite_user(room.id, self.user_id) 639 | else: 640 | self.send_notice(f"You are already in {room.name} ({room.id})") 641 | 642 | # if we found at least one network room, no need to create unless forced 643 | if found > 0 and not args.new: 644 | return 645 | 646 | name = network["name"] if found == 0 else f"{network['name']} {found + 1}" 647 | 648 | self.send_notice(f"You have been invited to {name}") 649 | await NetworkRoom.create(self.serv, network["name"], self.user_id, name) 650 | 651 | async def cmd_quit(self, args): 652 | rooms = self.serv.find_rooms(None, self.user_id) 653 | 654 | # disconnect each network room in first pass 655 | for room in rooms: 656 | if type(room) == NetworkRoom and room.conn and room.conn.connected: 657 | self.send_notice(f"Disconnecting from {room.name}...") 658 | await room.cmd_disconnect(Namespace()) 659 | 660 | self.send_notice("Closing all channels and private messages...") 661 | 662 | # then just forget everything 663 | for room in rooms: 664 | if room.id == self.id: 665 | continue 666 | 667 | self.serv.unregister_room(room.id) 668 | 669 | try: 670 | await self.az.intent.leave_room(room.id) 671 | except MatrixRequestError: 672 | pass 673 | try: 674 | await self.az.intent.forget_room(room.id) 675 | except MatrixRequestError: 676 | pass 677 | 678 | self.send_notice("Goodbye!") 679 | await asyncio.sleep(1) 680 | raise RoomInvalidError("Leaving") 681 | 682 | async def cmd_version(self, args): 683 | self.send_notice(f"heisenbridge v{__version__}") 684 | -------------------------------------------------------------------------------- /heisenbridge/private_room.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import collections 3 | import html 4 | import logging 5 | import re 6 | import unicodedata 7 | from datetime import datetime 8 | from datetime import timezone 9 | from html import escape 10 | from typing import List 11 | from typing import Optional 12 | from typing import Tuple 13 | from urllib.parse import urlparse 14 | 15 | from mautrix.api import Method 16 | from mautrix.api import SynapseAdminPath 17 | from mautrix.errors import MatrixStandardRequestError 18 | from mautrix.types import MessageEvent 19 | from mautrix.types import TextMessageEventContent 20 | from mautrix.types.event.state import JoinRestriction 21 | from mautrix.types.event.state import JoinRestrictionType 22 | from mautrix.types.event.state import JoinRule 23 | from mautrix.types.event.state import JoinRulesStateEventContent 24 | from mautrix.types.event.type import EventType 25 | 26 | from heisenbridge.command_parse import CommandManager 27 | from heisenbridge.command_parse import CommandParser 28 | from heisenbridge.command_parse import CommandParserError 29 | from heisenbridge.room import Room 30 | 31 | 32 | class NetworkRoom: 33 | pass 34 | 35 | 36 | def unix_to_local(timestamp: Optional[str]): 37 | try: 38 | dt = datetime.fromtimestamp(int(timestamp), timezone.utc) 39 | return dt.strftime("%c %Z") # intentionally UTC for now 40 | except ValueError: 41 | logging.debug("Tried to convert '{timestamp}' to int") 42 | return timestamp 43 | 44 | 45 | def connected(f): 46 | def wrapper(*args, **kwargs): 47 | self = args[0] 48 | 49 | if not self.network or not self.network.conn or not self.network.conn.connected: 50 | self.send_notice("Need to be connected to use this command.") 51 | return asyncio.sleep(0) 52 | 53 | return f(*args, **kwargs) 54 | 55 | return wrapper 56 | 57 | 58 | def parse_irc_formatting(input: str, pills=None, color=None) -> Tuple[str, Optional[str]]: 59 | plain = [] 60 | formatted = [] 61 | 62 | color_table = collections.defaultdict( 63 | lambda: None, 64 | { 65 | "0": "#ffffff", 66 | "00": "#ffffff", 67 | "1": "#000000", 68 | "01": "#000000", 69 | "2": "#00007f", 70 | "02": "#00007f", 71 | "3": "#009300", 72 | "03": "#009300", 73 | "4": "#ff0000", 74 | "04": "#ff0000", 75 | "5": "#7f0000", 76 | "05": "#7f0000", 77 | "6": "#9c009c", 78 | "06": "#9c009c", 79 | "7": "#fc7f00", 80 | "07": "#fc7f00", 81 | "8": "#ffff00", 82 | "08": "#ffff00", 83 | "9": "#00fc00", 84 | "09": "#00fc00", 85 | "10": "#009393", 86 | "11": "#00ffff", 87 | "12": "#0000fc", 88 | "13": "#ff00ff", 89 | "14": "#7f7f7f", 90 | "15": "#d2d2d2", 91 | "16": "#470000", 92 | "17": "#472100", 93 | "18": "#474700", 94 | "19": "#324700", 95 | "20": "#004700", 96 | "21": "#00472c", 97 | "22": "#004747", 98 | "23": "#002747", 99 | "24": "#000047", 100 | "25": "#2e0047", 101 | "26": "#470047", 102 | "27": "#47002a", 103 | "28": "#740000", 104 | "29": "#743a00", 105 | "30": "#747400", 106 | "31": "#517400", 107 | "32": "#007400", 108 | "33": "#007449", 109 | "34": "#007474", 110 | "35": "#004074", 111 | "36": "#000074", 112 | "37": "#4b0074", 113 | "38": "#740074", 114 | "39": "#740045", 115 | "40": "#b50000", 116 | "41": "#b56300", 117 | "42": "#b5b500", 118 | "43": "#7db500", 119 | "44": "#00b500", 120 | "45": "#00b571", 121 | "46": "#00b5b5", 122 | "47": "#0063b5", 123 | "48": "#0000b5", 124 | "49": "#7500b5", 125 | "50": "#b500b5", 126 | "51": "#b5006b", 127 | "52": "#ff0000", 128 | "53": "#ff8c00", 129 | "54": "#ffff00", 130 | "55": "#b2ff00", 131 | "56": "#00ff00", 132 | "57": "#00ffa0", 133 | "58": "#00ffff", 134 | "59": "#008cff", 135 | "60": "#0000ff", 136 | "61": "#a500ff", 137 | "62": "#ff00ff", 138 | "63": "#ff0098", 139 | "64": "#ff5959", 140 | "65": "#ffb459", 141 | "66": "#ffff71", 142 | "67": "#cfff60", 143 | "68": "#6fff6f", 144 | "69": "#65ffc9", 145 | "70": "#6dffff", 146 | "71": "#59b4ff", 147 | "72": "#5959ff", 148 | "73": "#c459ff", 149 | "74": "#ff66ff", 150 | "75": "#ff59bc", 151 | "76": "#ff9c9c", 152 | "77": "#ffd39c", 153 | "78": "#ffff9c", 154 | "79": "#e2ff9c", 155 | "80": "#9cff9c", 156 | "81": "#9cffdb", 157 | "82": "#9cffff", 158 | "83": "#9cd3ff", 159 | "84": "#9c9cff", 160 | "85": "#dc9cff", 161 | "86": "#ff9cff", 162 | "87": "#ff94d3", 163 | "88": "#000000", 164 | "89": "#131313", 165 | "90": "#282828", 166 | "91": "#363636", 167 | "92": "#4d4d4d", 168 | "93": "#656565", 169 | "94": "#818181", 170 | "95": "#9f9f9f", 171 | "96": "#bcbcbc", 172 | "97": "#e2e2e2", 173 | "98": "#ffffff", 174 | }, 175 | ) 176 | 177 | have_formatting = False 178 | bold = False 179 | foreground = None 180 | background = None 181 | reversed = False 182 | monospace = False 183 | italic = False 184 | strikethrough = False 185 | underline = False 186 | 187 | for m in re.finditer( 188 | r"(\x02|\x03(?:([0-9]{1,2})(?:,([0-9]{1,2}))?)?|\x04(?:([0-9A-Fa-f]{6})(?:,([0-9A-Fa-f]{6}))?)?|\x11|\x1D|\x1E|\x1F|\x16|\x0F)?([^\x02\x03\x04\x11\x1D\x1E\x1F\x16\x0F]*)", # noqa: E501 189 | input, 190 | ): 191 | (ctrl, fg, bg, fghex, bghex, text) = (m.group(1), m.group(2), m.group(3), m.group(4), m.group(5), m.group(6)) 192 | 193 | if ctrl: 194 | have_formatting = True 195 | 196 | if underline: 197 | formatted.append("") 198 | if strikethrough: 199 | formatted.append("") 200 | if italic: 201 | formatted.append("") 202 | if monospace: 203 | formatted.append("") 204 | if color and (foreground is not None or background is not None): 205 | formatted.append("") 206 | if bold: 207 | formatted.append("") 208 | 209 | if ctrl[0] == "\x02": 210 | bold = not bold 211 | elif ctrl[0] == "\x03": 212 | foreground = color_table[fg] 213 | background = color_table[bg] 214 | elif ctrl[0] == "\x04": 215 | foreground = f"#{fghex}" 216 | background = f"#{bghex}" 217 | elif ctrl[0] == "\x11": 218 | monospace = not monospace 219 | elif ctrl[0] == "\x1D": 220 | italic = not italic 221 | elif ctrl[0] == "\x1E": 222 | strikethrough = not strikethrough 223 | elif ctrl[0] == "\x1F": 224 | underline = not underline 225 | elif ctrl[0] == "\x16": 226 | reversed = not reversed 227 | elif ctrl[0] == "\x0F": 228 | foreground = background = None 229 | bold = reversed = monospace = italic = strikethrough = underline = False 230 | 231 | if bold: 232 | formatted.append("") 233 | if color and (foreground is not None or background is not None): 234 | formatted.append("") 246 | if monospace: 247 | formatted.append("") 248 | if italic: 249 | formatted.append("") 250 | if strikethrough: 251 | formatted.append("") 252 | if underline: 253 | formatted.append("") 254 | 255 | if text: 256 | plain.append(text) 257 | 258 | # escape any existing html in the text 259 | text = escape(text) 260 | 261 | # create pills 262 | if pills: 263 | punct = "?!:;,." 264 | 265 | words = [] 266 | for word in text.split(" "): 267 | wlen = len(word) 268 | while wlen > 0 and word[wlen - 1] in punct: 269 | wlen -= 1 270 | 271 | word_start = word[:wlen].lower() 272 | word_end = word[wlen:] 273 | 274 | if word_start in pills: 275 | mxid, displayname = pills[word_start] 276 | words.append( 277 | f'{escape(displayname)}{word_end}' 278 | ) 279 | else: 280 | words.append(word) 281 | 282 | text = " ".join(words) 283 | 284 | # if the formatted version has a link, we took some pills 285 | if "") 292 | if strikethrough: 293 | formatted.append("") 294 | if italic: 295 | formatted.append("") 296 | if monospace: 297 | formatted.append("") 298 | if color and (foreground is not None or background is not None): 299 | formatted.append("") 300 | if bold: 301 | formatted.append("") 302 | 303 | return ("".join(plain), "".join(formatted) if have_formatting else None) 304 | 305 | 306 | def split_long(nick, user, host, target, message): 307 | out = [] 308 | 309 | # this is an easy template to calculate the overhead of the sender and target 310 | template = f":{nick}!{user}@{host} PRIVMSG {target} :\r\n" 311 | maxlen = 512 - len(template.encode()) 312 | dots = "..." 313 | 314 | words = [] 315 | for word in message.split(" "): 316 | words.append(word) 317 | line = " ".join(words) 318 | 319 | if len(line.encode()) + len(dots) > maxlen: 320 | words.pop() 321 | out.append(" ".join(words) + dots) 322 | words = [dots, word] 323 | 324 | out.append(" ".join(words)) 325 | 326 | return out 327 | 328 | 329 | # generate an edit that follows usual IRC conventions 330 | def line_diff(a, b): 331 | a = a.split() 332 | b = b.split() 333 | 334 | pre = None 335 | post = None 336 | mlen = min(len(a), len(b)) 337 | 338 | for i in range(0, mlen): 339 | if a[i] != b[i]: 340 | break 341 | 342 | pre = i + 1 343 | 344 | for i in range(1, mlen + 1): 345 | if a[-i] != b[-i]: 346 | break 347 | 348 | post = -i 349 | 350 | rem = a[pre:post] 351 | add = b[pre:post] 352 | 353 | if len(add) == 0 and len(rem) > 0: 354 | return "-" + (" ".join(rem)) 355 | 356 | if len(rem) == 0 and len(add) > 0: 357 | return "+" + (" ".join(add)) 358 | 359 | if len(add) > 0: 360 | return "* " + (" ".join(add)) 361 | 362 | return None 363 | 364 | 365 | class PrivateRoom(Room): 366 | # irc nick of the other party, name for consistency 367 | name: str 368 | network: Optional[NetworkRoom] 369 | network_id: str 370 | network_name: Optional[str] 371 | media: List[List[str]] 372 | 373 | max_lines = 0 374 | use_pastebin = False 375 | use_reacts = False 376 | force_forward = False 377 | prefix_all = False 378 | 379 | commands: CommandManager 380 | 381 | def init(self) -> None: 382 | self.name = None 383 | self.network = None 384 | self.network_id = None 385 | self.network_name = None # deprecated 386 | self.media = [] 387 | self.lazy_members = {} # allow lazy joining your own ghost for echo 388 | 389 | self.commands = CommandManager() 390 | 391 | if type(self) == PrivateRoom: 392 | cmd = CommandParser(prog="WHOIS", description="WHOIS the other user") 393 | self.commands.register(cmd, self.cmd_whois) 394 | 395 | cmd = CommandParser( 396 | prog="MAXLINES", description="set maximum number of lines per message until truncation or pastebin" 397 | ) 398 | cmd.add_argument("lines", type=int, nargs="?", help="Number of lines") 399 | self.commands.register(cmd, self.cmd_maxlines) 400 | 401 | cmd = CommandParser(prog="PASTEBIN", description="enable or disable automatic pastebin of long messages") 402 | cmd.add_argument("--enable", dest="enabled", action="store_true", help="Enable pastebin") 403 | cmd.add_argument( 404 | "--disable", dest="enabled", action="store_false", help="Disable pastebin (messages will be truncated)" 405 | ) 406 | cmd.set_defaults(enabled=None) 407 | self.commands.register(cmd, self.cmd_pastebin) 408 | 409 | cmd = CommandParser(prog="REACTS", description="enable or disable reacting to messages on splits/linking") 410 | cmd.add_argument("--enable", dest="enabled", action="store_true", help="Enable reacts") 411 | cmd.add_argument("--disable", dest="enabled", action="store_false", help="Disable reacts") 412 | cmd.set_defaults(enabled=None) 413 | self.commands.register(cmd, self.cmd_reacts) 414 | 415 | cmd = CommandParser( 416 | prog="PREFIXALL", description="prefix all bridged IRC lines with the user's nick, instead of just the first" 417 | ) 418 | cmd.add_argument("--enable", dest="enabled", action="store_true", help="Prefix all lines") 419 | cmd.add_argument("--disable", dest="enabled", action="store_false", help="Only prefix first line") 420 | cmd.set_defaults(enabled=None) 421 | self.commands.register(cmd, self.cmd_prefix_all) 422 | 423 | self.mx_register("m.room.message", self.on_mx_message) 424 | self.mx_register("m.room.redaction", self.on_mx_redaction) 425 | 426 | def from_config(self, config: dict) -> None: 427 | if "max_lines" in config: 428 | self.max_lines = config["max_lines"] 429 | 430 | if "use_pastebin" in config: 431 | self.use_pastebin = config["use_pastebin"] 432 | 433 | if "use_reacts" in config: 434 | self.use_reacts = config["use_reacts"] 435 | 436 | if "prefix_all" in config: 437 | self.prefix_all = config["prefix_all"] 438 | 439 | if "name" not in config: 440 | raise Exception("No name key in config for ChatRoom") 441 | 442 | self.name = config["name"] 443 | 444 | if "network_id" in config: 445 | self.network_id = config["network_id"] 446 | if "media" in config: 447 | self.media = config["media"] 448 | 449 | # only used for migration 450 | if "network" in config: 451 | self.network_name = config["network"] 452 | 453 | if self.network_name is None and self.network_id is None: 454 | raise Exception("No network or network_id key in config for PrivateRoom") 455 | 456 | def to_config(self) -> dict: 457 | return { 458 | "name": self.name, 459 | "network": self.network_name, 460 | "network_id": self.network_id, 461 | "media": self.media[:5], 462 | "max_lines": self.max_lines, 463 | "use_pastebin": self.use_pastebin, 464 | "use_reacts": self.use_reacts, 465 | "prefix_all": self.prefix_all, 466 | } 467 | 468 | @staticmethod 469 | def create(network: NetworkRoom, name: str) -> "PrivateRoom": 470 | logging.debug(f"PrivateRoom.create(network='{network.name}', name='{name}')") 471 | irc_user_id = network.serv.irc_user_id(network.name, name) 472 | room = PrivateRoom( 473 | None, 474 | network.user_id, 475 | network.serv, 476 | [network.user_id, irc_user_id, network.serv.user_id], 477 | [], 478 | ) 479 | room.name = name.lower() 480 | room.network = network 481 | room.network_id = network.id 482 | room.network_name = network.name 483 | 484 | room.max_lines = network.serv.config["max_lines"] 485 | room.use_pastebin = network.serv.config["use_pastebin"] 486 | room.use_reacts = network.serv.config["use_reacts"] 487 | 488 | asyncio.ensure_future(room._create_mx(name)) 489 | return room 490 | 491 | async def _create_mx(self, displayname) -> None: 492 | if self.id is None: 493 | irc_user_id = await self.network.serv.ensure_irc_user_id(self.network.name, displayname, update_cache=False) 494 | self.id = await self.network.serv.create_room( 495 | "{} ({})".format(displayname, self.network.name), 496 | "Private chat with {} on {}".format(displayname, self.network.name), 497 | [self.network.user_id, irc_user_id], 498 | ) 499 | self.serv.register_room(self) 500 | await self.az.intent.user(irc_user_id).ensure_joined(self.id) 501 | await self.save() 502 | # start event queue now that we have an id 503 | self._queue.start() 504 | 505 | # attach to network space 506 | if self.network.space: 507 | await self.network.space.attach(self.id) 508 | 509 | def is_valid(self) -> bool: 510 | if self.network_id is None and self.network_name is None: 511 | return False 512 | 513 | if self.name is None: 514 | return False 515 | 516 | if self.user_id is None: 517 | return False 518 | 519 | if not self.in_room(self.user_id): 520 | return False 521 | 522 | return True 523 | 524 | def cleanup(self) -> None: 525 | logging.debug(f"Cleaning up network connected room {self.id}.") 526 | 527 | # cleanup us from network space if we have it 528 | if self.network and self.network.space: 529 | asyncio.ensure_future(self.network.space.detach(self.id)) 530 | 531 | # cleanup us from network rooms 532 | if self.network and self.name in self.network.rooms: 533 | logging.debug(f"... and we are attached to network {self.network.id}, detaching.") 534 | del self.network.rooms[self.name] 535 | 536 | # if leaving this room invalidated the network, clean it up 537 | if not self.network.is_valid(): 538 | logging.debug(f"... and we invalidated network {self.network.id} while cleaning up.") 539 | self.network.serv.unregister_room(self.network.id) 540 | self.network.cleanup() 541 | asyncio.ensure_future(self.network.serv.leave_room(self.network.id, self.network.members)) 542 | 543 | super().cleanup() 544 | 545 | def send_notice( 546 | self, 547 | text: str, 548 | user_id: Optional[str] = None, 549 | formatted=None, 550 | fallback_html: Optional[str] = None, 551 | forward=False, 552 | ): 553 | if (self.force_forward or forward or self.network.forward) and user_id is None: 554 | self.network.send_notice(text=f"{self.name}: {text}", formatted=formatted, fallback_html=fallback_html) 555 | else: 556 | super().send_notice(text=text, user_id=user_id, formatted=formatted, fallback_html=fallback_html) 557 | 558 | def send_notice_html(self, text: str, user_id: Optional[str] = None, forward=False) -> None: 559 | if (self.force_forward or forward or self.network.forward) and user_id is None: 560 | self.network.send_notice_html(text=f"{self.name}: {text}") 561 | else: 562 | super().send_notice_html(text=text, user_id=user_id) 563 | 564 | def pills(self): 565 | # if pills are disabled, don't generate any 566 | if self.network.pills_length < 1: 567 | return None 568 | 569 | ret = {} 570 | ignore = list(map(lambda x: x.lower(), self.network.pills_ignore)) 571 | 572 | # push our own name first 573 | lnick = self.network.conn.real_nickname.lower() 574 | if self.user_id in self.displaynames and len(lnick) >= self.network.pills_length and lnick not in ignore: 575 | ret[lnick] = (self.user_id, self.displaynames[self.user_id]) 576 | 577 | # assuming displayname of a puppet matches nick 578 | for member in self.members: 579 | if not member.startswith("@" + self.serv.puppet_prefix) or not member.endswith(":" + self.serv.server_name): 580 | continue 581 | 582 | if member in self.displaynames: 583 | nick = self.displaynames[member] 584 | lnick = nick.lower() 585 | if len(nick) >= self.network.pills_length and lnick not in ignore: 586 | ret[lnick] = (member, nick) 587 | 588 | return ret 589 | 590 | def on_privmsg(self, conn, event) -> None: 591 | if self.network is None: 592 | return 593 | 594 | irc_user_id = self.serv.irc_user_id(self.network.name, event.source.nick) 595 | 596 | (plain, formatted) = parse_irc_formatting(event.arguments[0], self.pills(), self.network.color) 597 | 598 | # ignore relaymsgs by us 599 | if event.tags: 600 | for tag in event.tags: 601 | if tag["key"] == "draft/relaymsg" and tag["value"] == self.network.conn.real_nickname: 602 | return 603 | 604 | if event.source.nick == self.network.conn.real_nickname: 605 | source_irc_user_id = self.serv.irc_user_id(self.network.name, event.source.nick) 606 | 607 | if self.lazy_members is None: 608 | self.send_message(f"You said: {plain}", formatted=(f"You said: {formatted}" if formatted else None)) 609 | return 610 | elif source_irc_user_id not in self.lazy_members: 611 | # if we are a PM room, remove all other IRC users than the target 612 | if type(self) == PrivateRoom: 613 | target_irc_user_id = self.serv.irc_user_id(self.network.name, self.name) 614 | 615 | for user_id in self.members: 616 | if user_id.startswith("@" + self.serv.puppet_prefix) and user_id != target_irc_user_id: 617 | if user_id in self.lazy_members: 618 | del self.lazy_members[user_id] 619 | self.leave(user_id) 620 | 621 | # add self to lazy members list so it'll echo 622 | self.lazy_members[source_irc_user_id] = event.source.nick 623 | 624 | if ( 625 | "twitch.tv/membership" in self.network.caps 626 | and irc_user_id not in self.members 627 | and irc_user_id not in self.lazy_members 628 | ): 629 | self.lazy_members[irc_user_id] = event.source.nick 630 | 631 | self.send_message( 632 | plain, 633 | irc_user_id, 634 | formatted=formatted, 635 | fallback_html=f"Message from {str(event.source)}: {html.escape(plain)}", 636 | ) 637 | 638 | # lazy update displayname if we detect a change 639 | if ( 640 | not self.serv.is_user_cached(irc_user_id, event.source.nick) 641 | and irc_user_id not in (self.lazy_members or {}) 642 | and irc_user_id in self.members 643 | ): 644 | asyncio.ensure_future(self.serv.ensure_irc_user_id(self.network.name, event.source.nick)) 645 | 646 | def on_privnotice(self, conn, event) -> None: 647 | if self.network is None: 648 | return 649 | 650 | (plain, formatted) = parse_irc_formatting(event.arguments[0]) 651 | 652 | if event.source.nick == self.network.conn.real_nickname: 653 | self.send_notice(f"You noticed: {plain}", formatted=(f"You noticed: {formatted}" if formatted else None)) 654 | return 655 | 656 | # if the local user has left this room notify in network 657 | if self.user_id not in self.members: 658 | source = self.network.source_text(conn, event) 659 | self.network.send_notice_html( 660 | f"Notice from {source}: {formatted if formatted else html.escape(plain)}" 661 | ) 662 | return 663 | 664 | irc_user_id = self.serv.irc_user_id(self.network.name, event.source.nick) 665 | self.send_notice( 666 | plain, 667 | irc_user_id, 668 | formatted=formatted, 669 | fallback_html=f"Notice from {str(event.source)}: {formatted if formatted else html.escape(plain)}", 670 | ) 671 | 672 | def on_ctcp(self, conn, event) -> None: 673 | if self.network is None: 674 | return 675 | 676 | # ignore relaymsgs by us 677 | if event.tags: 678 | for tag in event.tags: 679 | if tag["key"] == "draft/relaymsg" and tag["value"] == self.network.conn.real_nickname: 680 | return 681 | 682 | irc_user_id = self.serv.irc_user_id(self.network.name, event.source.nick) 683 | 684 | command = event.arguments[0].upper() 685 | 686 | if command == "ACTION" and len(event.arguments) > 1: 687 | (plain, formatted) = parse_irc_formatting(event.arguments[1]) 688 | 689 | if event.source.nick == self.network.conn.real_nickname: 690 | self.send_emote(f"(you) {plain}") 691 | return 692 | 693 | self.send_emote( 694 | plain, irc_user_id, fallback_html=f"Emote from {str(event.source)}: {html.escape(plain)}" 695 | ) 696 | else: 697 | (plain, formatted) = parse_irc_formatting(" ".join(event.arguments)) 698 | self.send_notice_html(f"{str(event.source)} requested CTCP {html.escape(plain)} (ignored)") 699 | 700 | def on_ctcpreply(self, conn, event) -> None: 701 | if self.network is None: 702 | return 703 | 704 | (plain, formatted) = parse_irc_formatting(" ".join(event.arguments)) 705 | self.send_notice_html(f"{str(event.source)} sent CTCP REPLY {html.escape(plain)} (ignored)") 706 | 707 | async def _process_event_content(self, event, prefix, reply_to=None): 708 | content = event.content 709 | 710 | if content.formatted_body: 711 | lines = str(await self.parser.parse(content.formatted_body)).replace("\r", "").split("\n") 712 | elif content.body: 713 | lines = content.body.replace("\r", "").split("\n") 714 | else: 715 | logging.warning("_process_event_content called with no usable body") 716 | return 717 | 718 | # drop all whitespace-only lines 719 | lines = [x for x in lines if not re.match(r"^\s*$", x)] 720 | 721 | # handle replies 722 | if reply_to and reply_to.sender != event.sender: 723 | # resolve displayname 724 | sender = reply_to.sender 725 | if sender in self.displaynames: 726 | sender = self.displaynames[sender] 727 | 728 | # prefix first line with nickname of the reply_to source 729 | first_line = sender + ": " + lines.pop(0) 730 | lines.insert(0, first_line) 731 | 732 | messages = [] 733 | 734 | for i, line in enumerate(lines): 735 | # prefix line if needed 736 | if (i == 0 or self.prefix_all) and prefix and len(prefix) > 0: 737 | line = prefix + line 738 | 739 | # filter control characters except ZWSP 740 | line = "".join(c for c in line if unicodedata.category(c)[0] != "C" or c == "\u200B") 741 | 742 | messages += split_long( 743 | self.network.conn.real_nickname, 744 | self.network.real_user, 745 | self.network.real_host, 746 | self.name, 747 | line, 748 | ) 749 | 750 | return messages 751 | 752 | async def _send_message(self, event, func, prefix=""): 753 | # try to find out if this was a reply 754 | reply_to = None 755 | if event.content.get_reply_to(): 756 | rel_event = event 757 | 758 | # traverse back all edits 759 | while rel_event.content.get_edit(): 760 | rel_event = await self.az.intent.get_event(self.id, rel_event.content.get_edit()) 761 | 762 | # see if the original is a reply 763 | if rel_event.content.get_reply_to(): 764 | reply_to = await self.az.intent.get_event(self.id, rel_event.content.get_reply_to()) 765 | 766 | if event.content.get_edit(): 767 | messages = await self._process_event_content(event, prefix, reply_to) 768 | event_id = event.content.relates_to.event_id 769 | prev_event = self.last_messages[event.sender] 770 | if prev_event and prev_event.event_id == event_id: 771 | old_messages = await self._process_event_content(prev_event, prefix, reply_to) 772 | 773 | mlen = max(len(messages), len(old_messages)) 774 | edits = [] 775 | for i in range(0, mlen): 776 | try: 777 | old_msg = old_messages[i] 778 | except IndexError: 779 | old_msg = "" 780 | try: 781 | new_msg = messages[i] 782 | except IndexError: 783 | new_msg = "" 784 | 785 | edit = line_diff(old_msg, new_msg) 786 | if edit: 787 | edits.append(prefix + edit) 788 | 789 | # use edits only if one line was edited 790 | if len(edits) == 1: 791 | messages = edits 792 | 793 | # update last message _content_ to current so re-edits work 794 | self.last_messages[event.sender].content = event.content 795 | else: 796 | # last event was not found so we fall back to full message BUT we can reconstrut enough of it 797 | self.last_messages[event.sender] = event 798 | else: 799 | # keep track of the last message 800 | self.last_messages[event.sender] = event 801 | messages = await self._process_event_content(event, prefix, reply_to) 802 | 803 | for i, message in enumerate(messages): 804 | if self.max_lines > 0 and i == self.max_lines - 1 and len(messages) > self.max_lines: 805 | if self.use_reacts: 806 | self.react(event.event_id, "\u2702") # scissors 807 | 808 | if self.use_pastebin: 809 | content_uri = await self.az.intent.upload_media( 810 | "\n".join(messages).encode("utf-8"), mime_type="text/plain; charset=UTF-8" 811 | ) 812 | 813 | if self.max_lines == 1: 814 | func( 815 | self.name, 816 | f"{prefix}{self.serv.mxc_to_url(str(content_uri))} (long message, {len(messages)} lines)", 817 | ) 818 | else: 819 | func( 820 | self.name, 821 | f"... long message truncated: {self.serv.mxc_to_url(str(content_uri))} ({len(messages)} lines)", 822 | ) 823 | if self.use_reacts: 824 | self.react(event.event_id, "\U0001f4dd") # memo 825 | 826 | self.media.append([event.event_id, str(content_uri)]) 827 | await self.save() 828 | else: 829 | if self.max_lines == 1: 830 | # best effort is to send the first line and give up 831 | func(self.name, message) 832 | else: 833 | func(self.name, "... long message truncated") 834 | 835 | return 836 | 837 | func(self.name, message) 838 | 839 | # show number of lines sent to IRC 840 | if self.use_reacts and self.max_lines == 0 and len(messages) > 1: 841 | self.react(event.event_id, f"\u2702 {len(messages)} lines") 842 | 843 | async def on_mx_message(self, event) -> None: 844 | if event.sender != self.user_id: 845 | return 846 | 847 | if self.network is None or self.network.conn is None or not self.network.conn.connected: 848 | self.send_notice("Not connected to network.") 849 | return 850 | 851 | if str(event.content.msgtype) == "m.emote": 852 | await self._send_message(event, self.network.conn.action) 853 | elif str(event.content.msgtype) in ["m.image", "m.file", "m.audio", "m.video"]: 854 | if event.content.filename and event.content.filename != event.content.body: 855 | new_body = self.serv.mxc_to_url(event.content.url, event.content.filename) + "\n" + event.content.body 856 | else: 857 | new_body = self.serv.mxc_to_url(event.content.url, event.content.body) 858 | media_event = MessageEvent( 859 | sender=event.sender, 860 | type=None, 861 | room_id=None, 862 | event_id=None, 863 | timestamp=None, 864 | content=TextMessageEventContent(body=new_body), 865 | ) 866 | await self._send_message(media_event, self.network.conn.privmsg) 867 | if self.use_reacts: 868 | self.react(event.event_id, "\U0001F517") # link 869 | self.media.append([event.event_id, event.content.url]) 870 | await self.save() 871 | elif str(event.content.msgtype) == "m.text": 872 | # allow commanding the appservice in rooms 873 | match = re.match(r"^\s*@?([^:,\s]+)[\s:,]*(.+)$", event.content.body) 874 | if match and match.group(1).lower() == self.serv.registration["sender_localpart"]: 875 | try: 876 | await self.commands.trigger(match.group(2)) 877 | except CommandParserError as e: 878 | self.send_notice(str(e)) 879 | finally: 880 | return 881 | 882 | await self._send_message(event, self.network.conn.privmsg) 883 | 884 | await self.az.intent.send_receipt(event.room_id, event.event_id) 885 | 886 | async def on_mx_redaction(self, event) -> None: 887 | for media in self.media: 888 | if media[0] == event.redacts: 889 | url = urlparse(media[1]) 890 | if self.serv.synapse_admin: 891 | try: 892 | await self.az.intent.api.request( 893 | Method.POST, SynapseAdminPath.v1.media.quarantine[url.netloc][url.path[1:]] 894 | ) 895 | 896 | self.network.send_notice( 897 | f"Associated media {media[1]} for redacted event {event.redacts} " 898 | + f"in room {self.name} was quarantined." 899 | ) 900 | except Exception: 901 | self.network.send_notice( 902 | f"Failed to quarantine media! Associated media {media[1]} " 903 | + f"for redacted event {event.redacts} in room {self.name} is left available." 904 | ) 905 | else: 906 | self.network.send_notice( 907 | f"No permission to quarantine media! Associated media {media[1]} " 908 | + f"for redacted event {event.redacts} in room {self.name} is left available." 909 | ) 910 | return 911 | 912 | @connected 913 | async def cmd_whois(self, args) -> None: 914 | self.network.conn.whois(f"{self.name} {self.name}") 915 | 916 | async def cmd_maxlines(self, args) -> None: 917 | if args.lines is not None: 918 | self.max_lines = args.lines 919 | await self.save() 920 | 921 | self.send_notice(f"Max lines is {self.max_lines}") 922 | 923 | async def cmd_pastebin(self, args) -> None: 924 | if args.enabled is not None: 925 | self.use_pastebin = args.enabled 926 | await self.save() 927 | 928 | self.send_notice(f"Pastebin is {'enabled' if self.use_pastebin else 'disabled'}") 929 | 930 | async def cmd_reacts(self, args) -> None: 931 | if args.enabled is not None: 932 | self.use_reacts = args.enabled 933 | await self.save() 934 | 935 | self.send_notice(f"Reacts are {'enabled' if self.use_reacts else 'disabled'}") 936 | 937 | async def cmd_prefix_all(self, args) -> None: 938 | if args.enabled is not None: 939 | self.prefix_all = args.enabled 940 | await self.save() 941 | 942 | self.send_notice(f"Prefix all is {'enabled' if self.prefix_all else 'disabled'}") 943 | 944 | async def _attach_hidden_room_internal(self) -> None: 945 | await self.az.intent.send_state_event( 946 | self.id, 947 | EventType.ROOM_JOIN_RULES, 948 | content=JoinRulesStateEventContent( 949 | join_rule=JoinRule.RESTRICTED, 950 | allow=[ 951 | JoinRestriction(type=JoinRestrictionType.ROOM_MEMBERSHIP, room_id=self.serv.hidden_room.id), 952 | ], 953 | ), 954 | ) 955 | self.hidden_room_id = self.serv.hidden_room.id 956 | 957 | async def _detach_hidden_room_internal(self) -> None: 958 | await self.az.intent.send_state_event( 959 | self.id, 960 | EventType.ROOM_JOIN_RULES, 961 | content=JoinRulesStateEventContent(join_rule=JoinRule.INVITE), 962 | ) 963 | self.hidden_room_id = None 964 | 965 | async def _attach_hidden_room(self) -> None: 966 | if self.hidden_room_id: 967 | self.send_notice("Room already has a hidden room attached.") 968 | return 969 | if not self.serv.hidden_room: 970 | self.send_notice("Server has no hidden room!") 971 | return 972 | 973 | logging.debug(f"Attaching room {self.id} to servers hidden room {self.serv.hidden_room.id}.") 974 | try: 975 | room_create = await self.az.intent.get_state_event(self.id, EventType.ROOM_CREATE) 976 | if room_create.room_version in [str(v) for v in range(1, 9)]: 977 | self.send_notice("Only rooms of version 9 or greater can be attached to a hidden room.") 978 | self.send_notice("Leave and re-create the room to ensure the correct version.") 979 | return 980 | 981 | await self._attach_hidden_room_internal() 982 | self.send_notice("Hidden room attached, invites should now be gone.") 983 | except MatrixStandardRequestError as e: 984 | logging.debug("Setting join_rules for hidden room failed.", exc_info=True) 985 | self.send_notice(f"Failed attaching hidden room: {e.message}") 986 | self.send_notice("Make sure the room is at least version 9.") 987 | except Exception: 988 | logging.exception(f"Failed to attach {self.id} to hidden room {self.serv.hidden_room.id}.") 989 | 990 | async def _detach_hidden_room(self) -> None: 991 | if not self.hidden_room_id: 992 | self.send_notice("Room already detached from hidden room.") 993 | return 994 | 995 | logging.debug(f"Detaching room {self.id} from hidden room {self.hidden_room_id}.") 996 | try: 997 | await self._detach_hidden_room_internal() 998 | self.send_notice("Hidden room detached.") 999 | except MatrixStandardRequestError as e: 1000 | logging.debug("Setting join_rules for hidden room failed.", exc_info=True) 1001 | self.send_notice(f"Failed detaching hidden room: {e.message}") 1002 | except Exception: 1003 | logging.exception(f"Failed to detach {self.id} from hidden room {self.hidden_room_id}.") 1004 | 1005 | async def cmd_upgrade(self, args) -> None: 1006 | if args.undo: 1007 | await self._detach_hidden_room() 1008 | else: 1009 | await self._attach_hidden_room() 1010 | 1011 | async def post_init(self) -> None: 1012 | if self.hidden_room_id and not self.serv.hidden_room: 1013 | logging.debug( 1014 | f"Server has no hidden room, detaching room {self.id} from hidden room {self.hidden_room_id}." 1015 | ) 1016 | await self._detach_hidden_room_internal() 1017 | elif self.hidden_room_id and self.hidden_room_id != self.serv.hidden_room.id: 1018 | logging.debug(f"Server has different hidden room, reattaching room {self.id}.") 1019 | await self._attach_hidden_room_internal() 1020 | --------------------------------------------------------------------------------