├── bridge ├── cogs │ └── .keep ├── requirements.txt ├── README.md └── bridge.py ├── appservice ├── requirements.txt ├── cache.py ├── errors.py ├── matrix.py ├── misc.py ├── db.py ├── README.md ├── discord.py ├── appservice.py ├── gateway.py └── main.py ├── demo.png ├── LICENSE ├── README.md └── misc └── migrate_emotes.py /bridge/cogs/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bridge/requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py==2.0.1 2 | matrix-nio==0.19.0 3 | -------------------------------------------------------------------------------- /appservice/requirements.txt: -------------------------------------------------------------------------------- 1 | bottle 2 | markdown 3 | urllib3 4 | websockets 5 | -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/git-bruh/matrix-discord-bridge/HEAD/demo.png -------------------------------------------------------------------------------- /appservice/cache.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | 4 | class Cache: 5 | cache = {} 6 | lock = threading.Lock() 7 | -------------------------------------------------------------------------------- /appservice/errors.py: -------------------------------------------------------------------------------- 1 | class RequestError(Exception): 2 | def __init__(self, status: int, *args): 3 | super().__init__(*args) 4 | 5 | self.status = status 6 | -------------------------------------------------------------------------------- /appservice/matrix.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class User: 6 | avatar_url: str = "" 7 | display_name: str = "" 8 | 9 | 10 | class Event: 11 | def __init__(self, event: dict): 12 | content = event.get("content", {}) 13 | 14 | self.attachment = content.get("url") 15 | self.body = content.get("body", "").strip() 16 | self.formatted_body = content.get("formatted_body", "") 17 | self.id = event["event_id"] 18 | self.is_direct = content.get("is_direct", False) 19 | self.redacts = event.get("redacts", "") 20 | self.room_id = event["room_id"] 21 | self.sender = event["sender"] 22 | self.state_key = event.get("state_key", "") 23 | 24 | rel = content.get("m.relates_to", {}) 25 | 26 | self.relates_to = rel.get("event_id") 27 | self.reltype = rel.get("rel_type") 28 | self.new_body = content.get("m.new_content", {}).get("body", "") 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 git-bruh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bridge/README.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | `pip install -r requirements.txt` 4 | 5 | ## Usage 6 | 7 | * Run `bridge.py` to generate `config.json` 8 | 9 | * Edit `config.json`: 10 | 11 | ``` 12 | { 13 | "homeserver": "https://matrix.org", 14 | "username": "@name:matrix.org", 15 | "password": "my-secret-password", # Matrix password. 16 | "token": "my-secret-token", # Discord bot token. 17 | "discord_cmd_prefix": "my-command-prefix", 18 | "bridge": { 19 | "channel_id": "room_id", 20 | "channel_id2": "room_id2", # Bridge multiple rooms. 21 | }, 22 | } 23 | ``` 24 | 25 | This bridge does not use databases for keeping track of bridged rooms to avoid a dependency on persistent storage. This makes it easy to host on something like Heroku with the free tier. 26 | 27 | * Logs are saved to the `bridge.log` file in `$PWD`. 28 | 29 | * Normal Discord bot functionality like commands can be added to the bot via [cogs](https://discordpy.readthedocs.io/en/latest/ext/commands/cogs.html), example [here](https://gist.github.com/EvieePy/d78c061a4798ae81be9825468fe146be). 30 | 31 | **NOTE:** [Privileged Intents](https://discordpy.readthedocs.io/en/latest/intents.html#privileged-intents) must be enabled for your Discord bot. 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # matrix-discord-bridge 2 | 3 | A simple bridge between Matrix and Discord written in Python. 4 | 5 | This repository contains two bridges: 6 | 7 | * A [puppeting appservice](appservice): The puppeting bridge written with minimal dependencies. Running this requires a self-hosted homeserver. 8 | 9 | * A [non-puppeting bridge](bridge): The non-puppeting plaintext bridge written with `matrix-nio` and `discord.py`, most people would want to use this one if running on heroku or similar and don't have their own server. **NOTE:** This is unmaintained and might break in the future due to Discord changes. 10 | 11 | Check their READMEs for specific information. 12 | 13 | ![Demo](demo.png) 14 | 15 | ## What Works 16 | 17 | - [x] Puppeting (Appservice only, regular bridge only uses webhooks on Discord.) 18 | - [x] Attachments (Converted to URLs.) 19 | - [x] Typing Indicators (Per-user indicators on Appservice, otherwise sent as bot user.) 20 | - [x] Message redaction 21 | - [x] Replies 22 | - [x] Bridging multiple channels 23 | - [x] Discord emojis displayed as inline images 24 | - [x] Sending Discord emotes from Matrix (`:emote_name:`) 25 | - [x] Mentioning Discord users via partial username (`@partialname`) 26 | 27 | ## TODO 28 | 29 | - [ ] Handle cases where the webhook is messed with on the Discord side (Deleted/Edited by someone other than the bot). 30 | - [ ] Use embeds on Discord side for replies. 31 | - [ ] Unbridging. 32 | -------------------------------------------------------------------------------- /appservice/misc.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import fields 3 | from typing import Any 4 | 5 | import urllib3 6 | 7 | from errors import RequestError 8 | 9 | 10 | def dict_cls(d: dict, cls: Any) -> Any: 11 | """ 12 | Create a dataclass from a dictionary. 13 | """ 14 | 15 | field_names = set(f.name for f in fields(cls)) 16 | filtered_dict = {k: v for k, v in d.items() if k in field_names} 17 | 18 | return cls(**filtered_dict) 19 | 20 | 21 | def log_except(fn): 22 | """ 23 | Log unhandled exceptions to a logger instead of `stderr`. 24 | """ 25 | 26 | def wrapper(self, *args, **kwargs): 27 | try: 28 | return fn(self, *args, **kwargs) 29 | except Exception: 30 | self.logger.exception(f"Exception in '{fn.__name__}':") 31 | raise 32 | 33 | return wrapper 34 | 35 | 36 | def request(fn): 37 | """ 38 | Either return json data or raise a `RequestError` if the request was 39 | unsuccessful. 40 | """ 41 | 42 | def wrapper(*args, **kwargs): 43 | try: 44 | resp = fn(*args, **kwargs) 45 | except urllib3.exceptions.HTTPError as e: 46 | raise RequestError(None, f"Failed to connect: {e}") from None 47 | 48 | if resp.status < 200 or resp.status >= 300: 49 | raise RequestError( 50 | resp.status, 51 | f"Failed to get response from '{resp.geturl()}':\n{resp.data}", 52 | ) 53 | 54 | return {} if resp.status == 204 else json.loads(resp.data) 55 | 56 | return wrapper 57 | 58 | 59 | def except_deleted(fn): 60 | """ 61 | Ignore the `RequestError` on 404s, the content might have been removed. 62 | """ 63 | 64 | def wrapper(*args, **kwargs): 65 | try: 66 | return fn(*args, **kwargs) 67 | except RequestError as e: 68 | if e.status != 404: 69 | raise 70 | 71 | return wrapper 72 | 73 | 74 | def hash_str(string: str) -> int: 75 | """ 76 | Create the hash for a string 77 | """ 78 | 79 | hash = 5381 80 | 81 | for ch in string: 82 | hash = ((hash << 5) + hash) + ord(ch) 83 | 84 | return hash & 0xFFFFFFFF 85 | -------------------------------------------------------------------------------- /appservice/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sqlite3 3 | import threading 4 | from typing import List 5 | 6 | 7 | class DataBase: 8 | def __init__(self, db_file) -> None: 9 | self.create(db_file) 10 | 11 | # The database is accessed via multiple threads. 12 | self.lock = threading.Lock() 13 | 14 | def create(self, db_file) -> None: 15 | """ 16 | Create a database with the relevant tables if it doesn't already exist. 17 | """ 18 | 19 | exists = os.path.exists(db_file) 20 | 21 | self.conn = sqlite3.connect(db_file, check_same_thread=False) 22 | self.conn.row_factory = self.dict_factory 23 | 24 | self.cur = self.conn.cursor() 25 | 26 | if exists: 27 | return 28 | 29 | self.cur.execute( 30 | "CREATE TABLE bridge(room_id TEXT PRIMARY KEY, channel_id TEXT);" 31 | ) 32 | 33 | self.cur.execute( 34 | "CREATE TABLE users(mxid TEXT PRIMARY KEY, " 35 | "avatar_url TEXT, username TEXT);" 36 | ) 37 | 38 | self.conn.commit() 39 | 40 | def dict_factory(self, cursor, row): 41 | """ 42 | https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.row_factory 43 | """ 44 | 45 | d = {} 46 | for idx, col in enumerate(cursor.description): 47 | d[col[0]] = row[idx] 48 | return d 49 | 50 | def add_room(self, room_id: str, channel_id: str) -> None: 51 | """ 52 | Add a bridged room to the database. 53 | """ 54 | 55 | with self.lock: 56 | self.cur.execute( 57 | "INSERT INTO bridge (room_id, channel_id) VALUES (?, ?)", 58 | [room_id, channel_id], 59 | ) 60 | self.conn.commit() 61 | 62 | def add_user(self, mxid: str) -> None: 63 | with self.lock: 64 | self.cur.execute("INSERT INTO users (mxid) VALUES (?)", [mxid]) 65 | self.conn.commit() 66 | 67 | def add_avatar(self, avatar_url: str, mxid: str) -> None: 68 | with self.lock: 69 | self.cur.execute( 70 | "UPDATE users SET avatar_url = (?) WHERE mxid = (?)", 71 | [avatar_url, mxid], 72 | ) 73 | self.conn.commit() 74 | 75 | def add_username(self, username: str, mxid: str) -> None: 76 | with self.lock: 77 | self.cur.execute( 78 | "UPDATE users SET username = (?) WHERE mxid = (?)", 79 | [username, mxid], 80 | ) 81 | self.conn.commit() 82 | 83 | def get_channel(self, room_id: str) -> str: 84 | """ 85 | Get the corresponding channel ID for a given room ID. 86 | """ 87 | 88 | with self.lock: 89 | self.cur.execute( 90 | "SELECT channel_id FROM bridge WHERE room_id = ?", [room_id] 91 | ) 92 | 93 | room = self.cur.fetchone() 94 | 95 | # Return an empty string if the channel is not bridged. 96 | return "" if not room else room["channel_id"] 97 | 98 | def list_channels(self) -> List[str]: 99 | """ 100 | Get a list of all the bridged channels. 101 | """ 102 | 103 | with self.lock: 104 | self.cur.execute("SELECT channel_id FROM bridge") 105 | 106 | channels = self.cur.fetchall() 107 | 108 | return [channel["channel_id"] for channel in channels] 109 | 110 | def fetch_user(self, mxid: str) -> dict: 111 | """ 112 | Fetch the profile for a bridged user. 113 | """ 114 | 115 | with self.lock: 116 | self.cur.execute("SELECT * FROM users where mxid = ?", [mxid]) 117 | 118 | user = self.cur.fetchone() 119 | 120 | return {} if not user else user 121 | -------------------------------------------------------------------------------- /appservice/README.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | `pip install -r requirements.txt` 4 | 5 | ## Usage 6 | 7 | * Run `main.py` to generate `appservice.json` 8 | 9 | * Edit `appservice.json`: 10 | 11 | ``` 12 | { 13 | "as_token": "my-secret-as-token", 14 | "hs_token": "my-secret-hs-token", 15 | "user_id": "appservice-discord", 16 | "homeserver": "http://127.0.0.1:8008", 17 | "server_name": "localhost", 18 | "discord_token": "my-secret-discord-token", 19 | "port": 5000, 20 | "database": "/path/to/bridge.db" 21 | } 22 | ``` 23 | 24 | `as_token`: The token sent by the appservice to the homeserver with events. 25 | 26 | `hs_token`: The token sent by the homeserver to the appservice with events. 27 | 28 | `user_id`: The username of the appservice user, it should match the `sender_localpart` in `appservice.yaml`. 29 | 30 | `homeserver`: A URL including the port where the homeserver is listening on. The default should work in most cases where the homeserver is running locally and listening for non-TLS connections on port `8008`. 31 | 32 | `server_name`: The server's name, it is the part after `:` in MXIDs. As an example, `kde.org` is the server name in `@testuser:kde.org`. 33 | 34 | `discord_token`: The Discord bot's token. 35 | 36 | `port`: The port where `bottle` will listen for events. 37 | 38 | `database`: Full path to the bridge's database. 39 | 40 | Both `as_token` and `hs_token` MUST be the same as their values in `appservice.yaml`. Their value can be set to anything, refer to the [spec](https://matrix.org/docs/spec/application_service/r0.1.2#registration). 41 | 42 | * Create `appservice.yaml` and add it to your homeserver: 43 | 44 | ``` 45 | id: "discord" 46 | url: "http://127.0.0.1:5000" 47 | as_token: "my-secret-as-token" 48 | hs_token: "my-secret-hs-token" 49 | sender_localpart: "appservice-discord" 50 | namespaces: 51 | users: 52 | - exclusive: true 53 | regex: "@_discord.*" 54 | - exclusive: true 55 | regex: "@appservice-discord" 56 | aliases: 57 | - exclusive: true 58 | regex: "#_discord.*" 59 | rooms: [] 60 | ``` 61 | 62 | The following lines should be added to the homeserver configuration. The full path to `appservice.yaml` might be required: 63 | 64 | * `synapse`: 65 | 66 | ``` 67 | # A list of application service config files to use 68 | # 69 | app_service_config_files: 70 | - appservice.yaml 71 | ``` 72 | 73 | * `dendrite`: 74 | 75 | ``` 76 | app_service_api: 77 | internal_api: 78 | # ... 79 | database: 80 | # ... 81 | config_files: [appservice.yaml] 82 | ``` 83 | 84 | A path can optionally be passed as the first argument to `main.py`. This path will be used as the base directory for the database and log file. 85 | 86 | Eg. Running `python3 main.py /path/to/my/dir` will store the database and logs in `/path/to/my/dir`. 87 | `$PWD` is used by default if no path is specified. 88 | 89 | After setting up the bridge, send a direct message to `@appservice-discord:domain.tld` containing the channel ID to be bridged (`!bridge 123456`). 90 | 91 | This bridge is written with: 92 | 93 | * `bottle`: Receiving events from the homeserver. 94 | * `urllib3`: Sending requests, thread safety. 95 | * `websockets`: Connecting to Discord. (Big thanks to an anonymous person "nesslersreagent" for figuring out the initial connection mess.) 96 | 97 | ## NOTES 98 | 99 | * A basic sqlite database is used for keeping track of bridged rooms. 100 | 101 | * Discord users can be tagged only by mentioning the dummy Matrix user, which requires the client to send a formatted body containing HTML. Partial mentions are not used to avoid unreliable queries to the websocket. 102 | 103 | * Logs are saved to the `appservice.log` file in `$PWD` or the specified directory. 104 | 105 | * For avatars to show up on Discord, you must have a [reverse proxy](https://github.com/matrix-org/dendrite/blob/master/docs/nginx/monolith-sample.conf) set up on your homeserver as the bridge does not specify the homeserver port when passing the avatar url. 106 | 107 | * It is not possible to add "normal" Discord bot functionality like commands as this bridge does not use `discord.py`. 108 | 109 | * [Privileged Intents](https://discordpy.readthedocs.io/en/latest/intents.html#privileged-intents) for members and presence must be enabled for your Discord bot. 110 | 111 | * This Appservice might not work well for bridging a large number of rooms since it is mostly synchronous. However, it wouldn't take much effort to port it to `asyncio` and `aiohttp` if desired. 112 | -------------------------------------------------------------------------------- /misc/migrate_emotes.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import os 5 | import sys 6 | import uuid 7 | import aiofiles 8 | import aiofiles.os 9 | import aiohttp 10 | import discord 11 | import nio 12 | 13 | 14 | def config_gen(config_file): 15 | config_dict = { 16 | "homeserver": "https://matrix.org", 17 | "username": "@name:matrix.org", 18 | "password": "my-secret-password", 19 | "token": "my-secret-token", 20 | "migrate": {"guild_id": "room_id"}, 21 | } 22 | 23 | if not os.path.exists(config_file): 24 | with open(config_file, "w") as f: 25 | json.dump(config_dict, f, indent=4) 26 | print(f"Example configuration dumped to {config_file}") 27 | sys.exit() 28 | 29 | with open(config_file, "r") as f: 30 | config = json.loads(f.read()) 31 | 32 | return config 33 | 34 | 35 | config = config_gen("config.json") 36 | 37 | 38 | class MatrixClient(nio.AsyncClient): 39 | def __init__(self, *args, **kwargs): 40 | super().__init__(*args, **kwargs) 41 | 42 | self.logger = logging.getLogger("matrix_logger") 43 | self.uploaded_emotes = {} 44 | 45 | async def start(self, discord_client): 46 | timeout = 30000 47 | 48 | self.logger.info(await self.login(config["password"])) 49 | 50 | self.logger.info("Syncing...") 51 | await self.sync(timeout) 52 | 53 | await discord_client.wait_until_ready() 54 | await discord_client.migrate() 55 | 56 | async def upload_emote(self, emote): 57 | emote_name = f":{emote.name}:" 58 | emote_file = f"/tmp/{str(uuid.uuid4())}" 59 | 60 | async with aiohttp.ClientSession() as session: 61 | async with session.get(str(emote.url)) as resp: 62 | emote_ = await resp.read() 63 | content_type = resp.content_type 64 | 65 | async with aiofiles.open(emote_file, "wb") as f: 66 | await f.write(emote_) 67 | 68 | async with aiofiles.open(emote_file, "rb") as f: 69 | resp, maybe_keys = await self.upload(f, content_type=content_type) 70 | 71 | await aiofiles.os.remove(emote_file) 72 | 73 | if type(resp) != nio.UploadResponse: 74 | self.logger.warning(f"Failed to upload {emote_name}") 75 | return 76 | 77 | self.logger.info(f"Uploaded {emote_name}") 78 | 79 | url = resp.content_uri 80 | 81 | self.uploaded_emotes[emote_name] = {} 82 | self.uploaded_emotes[emote_name]["url"] = url 83 | 84 | async def send_emote_state(self, room_id, emote_dict): 85 | event_type = "im.ponies.room_emotes" 86 | 87 | emotes = {} 88 | 89 | emotes_ = await self.room_get_state_event(room_id, event_type) 90 | 91 | # Get previous emotes from room 92 | if type(emotes_) != nio.RoomGetStateEventError: 93 | emotes = emotes_.content.get("emoticons") 94 | 95 | content = {"emoticons": {**emotes, **emote_dict}} 96 | 97 | resp = await self.room_put_state(room_id, event_type, content) 98 | 99 | if type(resp) == nio.RoomPutStateError: 100 | self.logger.warning(f"Failed to send emote state: {resp}") 101 | 102 | 103 | class DiscordClient(discord.Client): 104 | def __init__(self, *args, **kwargs): 105 | super().__init__(*args, **kwargs) 106 | 107 | self.matrix_client = MatrixClient( 108 | config["homeserver"], config["username"] 109 | ) 110 | 111 | self.bg_task = self.loop.create_task( 112 | self.log_exceptions(self.matrix_client) 113 | ) 114 | 115 | self.logger = logging.getLogger("discord_logger") 116 | 117 | async def log_exceptions(self, matrix_client): 118 | try: 119 | return await matrix_client.start(self) 120 | except Exception as e: 121 | matrix_client.logger.warning(f"Unknown exception occurred: {e}") 122 | 123 | await matrix_client.close() 124 | 125 | async def migrate(self): 126 | for guild in config["migrate"].keys(): 127 | emote_guild = self.get_guild(int(guild)) 128 | emote_room = config["migrate"][guild] 129 | 130 | if emote_guild: 131 | self.logger.info( 132 | f"Guild: {emote_guild.name} Room: {emote_room}" 133 | ) 134 | 135 | await asyncio.gather( 136 | *map(self.matrix_client.upload_emote, emote_guild.emojis) 137 | ) 138 | 139 | self.logger.info("Sending state event to room...") 140 | 141 | await self.matrix_client.send_emote_state( 142 | emote_room, self.matrix_client.uploaded_emotes 143 | ) 144 | 145 | self.logger.info("Finished uploading emotes") 146 | 147 | await self.matrix_client.logout() 148 | await self.matrix_client.close() 149 | 150 | await self.close() 151 | 152 | 153 | def main(): 154 | logging.basicConfig(level=logging.INFO) 155 | 156 | DiscordClient().run(config["token"]) 157 | 158 | 159 | if __name__ == "__main__": 160 | main() 161 | -------------------------------------------------------------------------------- /appservice/discord.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from misc import dict_cls 4 | 5 | CDN_URL = "https://cdn.discordapp.com" 6 | MESSAGE_LIMIT = 2000 7 | 8 | 9 | def bitmask(bit: int) -> int: 10 | return 1 << bit 11 | 12 | 13 | @dataclass 14 | class Channel: 15 | id: str 16 | type: str 17 | guild_id: str = "" 18 | name: str = "" 19 | topic: str = "" 20 | 21 | 22 | @dataclass 23 | class Emote: 24 | animated: bool 25 | id: str 26 | name: str 27 | 28 | 29 | @dataclass 30 | class MessageReference: 31 | message_id: str 32 | channel_id: str 33 | guild_id: str 34 | 35 | 36 | @dataclass 37 | class Sticker: 38 | name: str 39 | id: str 40 | format_type: int 41 | 42 | 43 | @dataclass 44 | class Typing: 45 | user_id: str 46 | channel_id: str 47 | 48 | 49 | @dataclass 50 | class Webhook: 51 | id: str 52 | token: str 53 | 54 | 55 | class User: 56 | def __init__(self, user: dict) -> None: 57 | self.discriminator = user["discriminator"] 58 | self.id = user["id"] 59 | self.mention = f"<@{self.id}>" 60 | self.username = user["username"] 61 | 62 | avatar = user["avatar"] 63 | 64 | if not avatar: 65 | # https://discord.com/developers/docs/reference#image-formatting 66 | self.avatar_url = ( 67 | f"{CDN_URL}/embed/avatars/{int(self.discriminator) % 5}.png" 68 | ) 69 | else: 70 | ext = "gif" if avatar.startswith("a_") else "png" 71 | self.avatar_url = f"{CDN_URL}/avatars/{self.id}/{avatar}.{ext}" 72 | 73 | 74 | class Guild: 75 | def __init__(self, guild: dict) -> None: 76 | self.guild_id = guild["id"] 77 | self.channels = [dict_cls(c, Channel) for c in guild["channels"]] 78 | self.emojis = [dict_cls(e, Emote) for e in guild["emojis"]] 79 | members = [member["user"] for member in guild["members"]] 80 | self.members = [User(m) for m in members] 81 | 82 | 83 | class GuildEmojisUpdate: 84 | def __init__(self, update: dict) -> None: 85 | self.guild_id = update["guild_id"] 86 | self.emojis = [dict_cls(e, Emote) for e in update["emojis"]] 87 | 88 | 89 | class GuildMembersChunk: 90 | def __init__(self, chunk: dict) -> None: 91 | self.chunk_index = chunk["chunk_index"] 92 | self.chunk_count = chunk["chunk_count"] 93 | self.guild_id = chunk["guild_id"] 94 | self.members = [User(m) for m in chunk["members"]] 95 | 96 | 97 | class GuildMemberUpdate: 98 | def __init__(self, update: dict) -> None: 99 | self.guild_id = update["guild_id"] 100 | self.user = User(update["user"]) 101 | 102 | 103 | class Message: 104 | def __init__(self, message: dict) -> None: 105 | self.attachments = message.get("attachments", []) 106 | self.channel_id = message["channel_id"] 107 | self.content = message.get("content", "") 108 | self.id = message["id"] 109 | self.guild_id = message.get( 110 | "guild_id", "" 111 | ) # Responses for sending webhook messages don't have guild_id 112 | self.webhook_id = message.get("webhook_id", "") 113 | self.application_id = message.get("application_id", "") 114 | 115 | self.mentions = [ 116 | User(mention) for mention in message.get("mentions", []) 117 | ] 118 | 119 | ref = message.get("referenced_message") 120 | 121 | self.referenced_message = Message(ref) if ref else None 122 | 123 | author = message.get("author") 124 | 125 | self.author = User(author) if author else None 126 | 127 | self.stickers = [ 128 | dict_cls(sticker, Sticker) 129 | for sticker in message.get("sticker_items", []) 130 | ] 131 | 132 | 133 | class ChannelType: 134 | GUILD_TEXT = 0 135 | DM = 1 136 | GUILD_VOICE = 2 137 | GROUP_DM = 3 138 | GUILD_CATEGORY = 4 139 | GUILD_NEWS = 5 140 | GUILD_STORE = 6 141 | 142 | 143 | class InteractionResponseType: 144 | PONG = 0 145 | ACKNOWLEDGE = 1 146 | CHANNEL_MESSAGE = 2 147 | CHANNEL_MESSAGE_WITH_SOURCE = 4 148 | ACKNOWLEDGE_WITH_SOURCE = 5 149 | 150 | 151 | class GatewayIntents: 152 | GUILDS = bitmask(0) 153 | GUILD_MEMBERS = bitmask(1) 154 | GUILD_BANS = bitmask(2) 155 | GUILD_EMOJIS = bitmask(3) 156 | GUILD_INTEGRATIONS = bitmask(4) 157 | GUILD_WEBHOOKS = bitmask(5) 158 | GUILD_INVITES = bitmask(6) 159 | GUILD_VOICE_STATES = bitmask(7) 160 | GUILD_PRESENCES = bitmask(8) 161 | GUILD_MESSAGES = bitmask(9) 162 | GUILD_MESSAGE_REACTIONS = bitmask(10) 163 | GUILD_MESSAGE_TYPING = bitmask(11) 164 | DIRECT_MESSAGES = bitmask(12) 165 | DIRECT_MESSAGE_REACTIONS = bitmask(13) 166 | DIRECT_MESSAGE_TYPING = bitmask(14) 167 | 168 | 169 | class GatewayOpCodes: 170 | DISPATCH = 0 171 | HEARTBEAT = 1 172 | IDENTIFY = 2 173 | PRESENCE_UPDATE = 3 174 | VOICE_STATE_UPDATE = 4 175 | RESUME = 6 176 | RECONNECT = 7 177 | REQUEST_GUILD_MEMBERS = 8 178 | INVALID_SESSION = 9 179 | HELLO = 10 180 | HEARTBEAT_ACK = 11 181 | 182 | 183 | class Payloads: 184 | def __init__(self, token: str) -> None: 185 | self.seq = self.session = None 186 | self.token = token 187 | 188 | def HEARTBEAT(self) -> dict: 189 | return {"op": GatewayOpCodes.HEARTBEAT, "d": self.seq} 190 | 191 | def IDENTIFY(self) -> dict: 192 | return { 193 | "op": GatewayOpCodes.IDENTIFY, 194 | "d": { 195 | "token": self.token, 196 | "intents": GatewayIntents.GUILDS 197 | | GatewayIntents.GUILD_EMOJIS 198 | | GatewayIntents.GUILD_MEMBERS 199 | | GatewayIntents.GUILD_MESSAGES 200 | | GatewayIntents.GUILD_MESSAGE_TYPING 201 | | GatewayIntents.GUILD_PRESENCES, 202 | "properties": { 203 | "$os": "discord", 204 | "$browser": "Discord Client", 205 | "$device": "discord", 206 | }, 207 | }, 208 | } 209 | 210 | def RESUME(self) -> dict: 211 | return { 212 | "op": GatewayOpCodes.RESUME, 213 | "d": { 214 | "token": self.token, 215 | "session_id": self.session, 216 | "seq": self.seq, 217 | }, 218 | } 219 | -------------------------------------------------------------------------------- /appservice/appservice.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import urllib.parse 4 | import uuid 5 | from typing import Union 6 | 7 | import bottle 8 | import urllib3 9 | 10 | import matrix 11 | from cache import Cache 12 | from misc import log_except, request 13 | 14 | 15 | class AppService(bottle.Bottle): 16 | def __init__(self, config: dict, http: urllib3.PoolManager) -> None: 17 | super(AppService, self).__init__() 18 | 19 | self.as_token = config["as_token"] 20 | self.hs_token = config["hs_token"] 21 | self.base_url = config["homeserver"] 22 | self.server_name = config["server_name"] 23 | self.user_id = f"@{config['user_id']}:{self.server_name}" 24 | self.http = http 25 | self.logger = logging.getLogger("appservice") 26 | 27 | # Map events to functions. 28 | self.mapping = { 29 | "m.room.member": "on_member", 30 | "m.room.message": "on_message", 31 | "m.room.redaction": "on_redaction", 32 | } 33 | 34 | # Add route for bottle. 35 | self.route( 36 | "/transactions/", 37 | callback=self.receive_event, 38 | method="PUT", 39 | ) 40 | 41 | Cache.cache["m_rooms"] = {} 42 | 43 | def handle_event(self, event: dict) -> None: 44 | event_type = event.get("type") 45 | 46 | if event_type in ( 47 | "m.room.member", 48 | "m.room.message", 49 | "m.room.redaction", 50 | ): 51 | obj = matrix.Event(event) 52 | else: 53 | self.logger.info(f"Unknown event type: {event_type}") 54 | return 55 | 56 | func = getattr(self, self.mapping[event_type], None) 57 | 58 | if not func: 59 | self.logger.warning( 60 | f"Function '{func}' not defined, ignoring event." 61 | ) 62 | return 63 | 64 | # We don't catch exceptions here as the homeserver will re-send us 65 | # the event in case of a failure. 66 | func(obj) 67 | 68 | @log_except 69 | def receive_event(self, transaction: str) -> dict: 70 | """ 71 | Verify the homeserver's token and handle events. 72 | """ 73 | 74 | hs_token = bottle.request.query.getone("access_token") 75 | 76 | if not hs_token: 77 | bottle.response.status = 401 78 | return {"errcode": "APPSERVICE_UNAUTHORIZED"} 79 | 80 | if hs_token != self.hs_token: 81 | bottle.response.status = 403 82 | return {"errcode": "APPSERVICE_FORBIDDEN"} 83 | 84 | events = bottle.request.json.get("events") 85 | 86 | for event in events: 87 | self.handle_event(event) 88 | 89 | return {} 90 | 91 | def mxc_url(self, mxc: str) -> str: 92 | try: 93 | homeserver, media_id = mxc.replace("mxc://", "").split("/") 94 | except ValueError: 95 | return "" 96 | 97 | return ( 98 | f"https://{self.server_name}/_matrix/media/r0/download/" 99 | f"{homeserver}/{media_id}" 100 | ) 101 | 102 | def join_room(self, room_id: str, mxid: str = "") -> None: 103 | self.send( 104 | "POST", 105 | f"/join/{room_id}", 106 | params={"user_id": mxid} if mxid else {}, 107 | ) 108 | 109 | def redact(self, event_id: str, room_id: str, mxid: str = "") -> None: 110 | self.send( 111 | "PUT", 112 | f"/rooms/{room_id}/redact/{event_id}/{uuid.uuid4()}", 113 | params={"user_id": mxid} if mxid else {}, 114 | ) 115 | 116 | def get_room_id(self, alias: str) -> str: 117 | with Cache.lock: 118 | room = Cache.cache["m_rooms"].get(alias) 119 | if room: 120 | return room 121 | 122 | resp = self.send("GET", f"/directory/room/{urllib.parse.quote(alias)}") 123 | 124 | room_id = resp["room_id"] 125 | 126 | with Cache.lock: 127 | Cache.cache["m_rooms"][alias] = room_id 128 | 129 | return room_id 130 | 131 | def get_event(self, event_id: str, room_id: str) -> matrix.Event: 132 | resp = self.send("GET", f"/rooms/{room_id}/event/{event_id}") 133 | 134 | return matrix.Event(resp) 135 | 136 | def upload(self, url: str) -> str: 137 | """ 138 | Upload a file to the homeserver and get the MXC url. 139 | """ 140 | 141 | resp = self.http.request("GET", url) 142 | 143 | resp = self.send( 144 | "POST", 145 | content=resp.data, 146 | content_type=resp.headers.get("Content-Type"), 147 | params={"filename": f"{uuid.uuid4()}"}, 148 | endpoint="/_matrix/media/r0/upload", 149 | ) 150 | 151 | return resp["content_uri"] 152 | 153 | def send_message( 154 | self, 155 | room_id: str, 156 | content: dict, 157 | mxid: str = "", 158 | ) -> str: 159 | resp = self.send( 160 | "PUT", 161 | f"/rooms/{room_id}/send/m.room.message/{uuid.uuid4()}", 162 | content, 163 | {"user_id": mxid} if mxid else {}, 164 | ) 165 | 166 | return resp["event_id"] 167 | 168 | def send_typing( 169 | self, room_id: str, mxid: str = "", timeout: int = 8000 170 | ) -> None: 171 | self.send( 172 | "PUT", 173 | f"/rooms/{room_id}/typing/{mxid}", 174 | {"typing": True, "timeout": timeout}, 175 | {"user_id": mxid} if mxid else {}, 176 | ) 177 | 178 | def send_invite(self, room_id: str, mxid: str) -> None: 179 | self.send("POST", f"/rooms/{room_id}/invite", {"user_id": mxid}) 180 | 181 | @request 182 | def send( 183 | self, 184 | method: str, 185 | path: str = "", 186 | content: Union[bytes, dict] = {}, 187 | params: dict = {}, 188 | content_type: str = "application/json", 189 | endpoint: str = "/_matrix/client/r0", 190 | ) -> dict: 191 | headers = { 192 | "Authorization": f"Bearer {self.as_token}", 193 | "Content-Type": content_type, 194 | } 195 | payload = json.dumps(content) if isinstance(content, dict) else content 196 | endpoint = ( 197 | f"{self.base_url}{endpoint}{path}?" 198 | f"{urllib.parse.urlencode(params)}" 199 | ) 200 | 201 | return self.http.request( 202 | method, endpoint, body=payload, headers=headers 203 | ) 204 | -------------------------------------------------------------------------------- /appservice/gateway.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import urllib.parse 5 | from typing import Dict, List 6 | 7 | import urllib3 8 | import websockets 9 | 10 | import discord 11 | from misc import dict_cls, log_except, request 12 | 13 | 14 | class Gateway: 15 | def __init__(self, http: urllib3.PoolManager, token: str): 16 | self.http = http 17 | self.token = token 18 | self.logger = logging.getLogger("discord") 19 | self.Payloads = discord.Payloads(self.token) 20 | self.websocket = None 21 | 22 | @log_except 23 | async def run(self) -> None: 24 | self.heartbeat_task: asyncio.Future = None 25 | self.resume = False 26 | 27 | gateway_url = self.get_gateway_url() 28 | 29 | while True: 30 | try: 31 | await self.gateway_handler(gateway_url) 32 | except ( 33 | websockets.ConnectionClosedError, 34 | websockets.InvalidMessage, 35 | ): 36 | self.logger.exception("Connection lost, reconnecting.") 37 | 38 | # Stop sending heartbeats until we reconnect. 39 | if self.heartbeat_task and not self.heartbeat_task.cancelled(): 40 | self.heartbeat_task.cancel() 41 | 42 | def get_gateway_url(self) -> str: 43 | resp = self.send("GET", "/gateway") 44 | 45 | return resp["url"] 46 | 47 | async def heartbeat_handler(self, interval_ms: int) -> None: 48 | while True: 49 | await asyncio.sleep(interval_ms / 1000) 50 | await self.websocket.send(json.dumps(self.Payloads.HEARTBEAT())) 51 | 52 | async def handle_resp(self, data: dict) -> None: 53 | data_dict = data["d"] 54 | 55 | opcode = data["op"] 56 | 57 | seq = data["s"] 58 | 59 | if seq: 60 | self.Payloads.seq = seq 61 | 62 | if opcode == discord.GatewayOpCodes.DISPATCH: 63 | otype = data["t"] 64 | 65 | if otype == "READY": 66 | self.Payloads.session = data_dict["session_id"] 67 | 68 | self.logger.info("READY") 69 | else: 70 | self.handle_otype(data_dict, otype) 71 | elif opcode == discord.GatewayOpCodes.HELLO: 72 | heartbeat_interval = data_dict.get("heartbeat_interval") 73 | 74 | self.logger.info(f"Heartbeat Interval: {heartbeat_interval}") 75 | 76 | # Send periodic hearbeats to gateway. 77 | self.heartbeat_task = asyncio.ensure_future( 78 | self.heartbeat_handler(heartbeat_interval) 79 | ) 80 | 81 | await self.websocket.send( 82 | json.dumps( 83 | self.Payloads.RESUME() 84 | if self.resume 85 | else self.Payloads.IDENTIFY() 86 | ) 87 | ) 88 | elif opcode == discord.GatewayOpCodes.RECONNECT: 89 | self.logger.info("Received RECONNECT.") 90 | 91 | self.resume = True 92 | await self.websocket.close() 93 | elif opcode == discord.GatewayOpCodes.INVALID_SESSION: 94 | self.logger.info("Received INVALID_SESSION.") 95 | 96 | self.resume = False 97 | await self.websocket.close() 98 | elif opcode == discord.GatewayOpCodes.HEARTBEAT_ACK: 99 | # NOP 100 | pass 101 | else: 102 | self.logger.info( 103 | "Unknown OP code: {opcode}\n{json.dumps(data, indent=4)}" 104 | ) 105 | 106 | def handle_otype(self, data: dict, otype: str) -> None: 107 | if otype in ("MESSAGE_CREATE", "MESSAGE_UPDATE", "MESSAGE_DELETE"): 108 | obj = discord.Message(data) 109 | elif otype == "TYPING_START": 110 | obj = dict_cls(data, discord.Typing) 111 | elif otype == "GUILD_CREATE": 112 | obj = discord.Guild(data) 113 | elif otype == "GUILD_MEMBER_UPDATE": 114 | obj = discord.GuildMemberUpdate(data) 115 | elif otype == "GUILD_EMOJIS_UPDATE": 116 | obj = discord.GuildEmojisUpdate(data) 117 | else: 118 | return 119 | 120 | func = getattr(self, f"on_{otype.lower()}", None) 121 | 122 | if not func: 123 | self.logger.warning( 124 | f"Function '{func}' not defined, ignoring message." 125 | ) 126 | return 127 | 128 | try: 129 | func(obj) 130 | except Exception: 131 | self.logger.exception(f"Ignoring exception in '{func.__name__}':") 132 | 133 | async def gateway_handler(self, gateway_url: str) -> None: 134 | async with websockets.connect( 135 | f"{gateway_url}/?v=8&encoding=json" 136 | ) as websocket: 137 | self.websocket = websocket 138 | 139 | async for message in websocket: 140 | await self.handle_resp(json.loads(message)) 141 | 142 | def get_channel(self, channel_id: str) -> discord.Channel: 143 | """ 144 | Get the channel for a given channel ID. 145 | """ 146 | 147 | resp = self.send("GET", f"/channels/{channel_id}") 148 | 149 | return dict_cls(resp, discord.Channel) 150 | 151 | def get_channels(self, guild_id: str) -> Dict[str, discord.Channel]: 152 | """ 153 | Get all channels for a given guild ID. 154 | """ 155 | 156 | resp = self.send("GET", f"/guilds/{guild_id}/channels") 157 | 158 | return { 159 | channel["id"]: dict_cls(channel, discord.Channel) 160 | for channel in resp 161 | } 162 | 163 | def get_emotes(self, guild_id: str) -> List[discord.Emote]: 164 | """ 165 | Get all the emotes for a given guild. 166 | """ 167 | 168 | resp = self.send("GET", f"/guilds/{guild_id}/emojis") 169 | 170 | return [dict_cls(emote, discord.Emote) for emote in resp] 171 | 172 | def get_members(self, guild_id: str) -> List[discord.User]: 173 | """ 174 | Get all the members for a given guild. 175 | """ 176 | 177 | resp = self.send( 178 | "GET", f"/guilds/{guild_id}/members", params={"limit": 1000} 179 | ) 180 | 181 | return [discord.User(member["user"]) for member in resp] 182 | 183 | def create_webhook(self, channel_id: str, name: str) -> discord.Webhook: 184 | """ 185 | Create a webhook with the specified name in a given channel. 186 | """ 187 | 188 | resp = self.send( 189 | "POST", f"/channels/{channel_id}/webhooks", {"name": name} 190 | ) 191 | 192 | return dict_cls(resp, discord.Webhook) 193 | 194 | def edit_webhook( 195 | self, content: str, message_id: str, webhook: discord.Webhook 196 | ) -> None: 197 | self.send( 198 | "PATCH", 199 | f"/webhooks/{webhook.id}/{webhook.token}/messages/" 200 | f"{message_id}", 201 | {"content": content}, 202 | ) 203 | 204 | def delete_webhook( 205 | self, message_id: str, webhook: discord.Webhook 206 | ) -> None: 207 | self.send( 208 | "DELETE", 209 | f"/webhooks/{webhook.id}/{webhook.token}/messages/" 210 | f"{message_id}", 211 | ) 212 | 213 | def send_webhook( 214 | self, 215 | webhook: discord.Webhook, 216 | avatar_url: str, 217 | content: str, 218 | username: str, 219 | ) -> discord.Message: 220 | payload = { 221 | "avatar_url": avatar_url, 222 | "content": content, 223 | "username": username, 224 | # Disable 'everyone' and 'role' mentions. 225 | "allowed_mentions": {"parse": ["users"]}, 226 | } 227 | 228 | resp = self.send( 229 | "POST", 230 | f"/webhooks/{webhook.id}/{webhook.token}", 231 | payload, 232 | {"wait": True}, 233 | ) 234 | 235 | return discord.Message(resp) 236 | 237 | def send_message(self, message: str, channel_id: str) -> None: 238 | self.send( 239 | "POST", f"/channels/{channel_id}/messages", {"content": message} 240 | ) 241 | 242 | @request 243 | def send( 244 | self, method: str, path: str, content: dict = {}, params: dict = {} 245 | ) -> dict: 246 | endpoint = ( 247 | f"https://discord.com/api/v8{path}?" 248 | f"{urllib.parse.urlencode(params)}" 249 | ) 250 | headers = { 251 | "Authorization": f"Bot {self.token}", 252 | "Content-Type": "application/json", 253 | } 254 | 255 | # 'body' being an empty dict breaks "GET" requests. 256 | payload = json.dumps(content) if content else None 257 | 258 | return self.http.request( 259 | method, endpoint, body=payload, headers=headers 260 | ) 261 | -------------------------------------------------------------------------------- /bridge/bridge.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import os 5 | import re 6 | import sys 7 | import uuid 8 | 9 | import aiofiles 10 | import aiofiles.os 11 | import aiohttp 12 | import discord 13 | import discord.ext.commands 14 | import nio 15 | 16 | 17 | def config_gen(config_file): 18 | config_dict = { 19 | "homeserver": "https://matrix.org", 20 | "username": "@name:matrix.org", 21 | "password": "my-secret-password", 22 | "token": "my-secret-token", 23 | "discord_cmd_prefix": "my-command-prefix", 24 | "bridge": {"channel_id": "room_id"}, 25 | } 26 | 27 | if not os.path.exists(config_file): 28 | with open(config_file, "w") as f: 29 | json.dump(config_dict, f, indent=4) 30 | print(f"Example configuration dumped to {config_file}") 31 | sys.exit() 32 | 33 | with open(config_file, "r") as f: 34 | config = json.loads(f.read()) 35 | 36 | return config 37 | 38 | 39 | config = config_gen("config.json") 40 | message_store = {} 41 | 42 | 43 | class MatrixClient(nio.AsyncClient): 44 | def __init__(self, *args, **kwargs): 45 | super().__init__(*args, **kwargs) 46 | 47 | self.logger = logging.getLogger("matrix_logger") 48 | 49 | self.listen = False 50 | self.uploaded_emotes = {} 51 | self.ready = asyncio.Event() 52 | self.loop = asyncio.get_event_loop() 53 | 54 | self.start_discord() 55 | self.add_callbacks() 56 | 57 | def start_discord(self): 58 | # Intents to fetch members from guild. 59 | intents = discord.Intents.all() 60 | intents.members = True 61 | 62 | self.discord_client = DiscordClient( 63 | self, 64 | allowed_mentions=discord.AllowedMentions( 65 | everyone=False, roles=False 66 | ), 67 | command_prefix=config["discord_cmd_prefix"], 68 | intents=intents, 69 | ) 70 | 71 | self.bg_task = self.loop.create_task( 72 | self.discord_client.start(config["token"]) 73 | ) 74 | 75 | def add_callbacks(self): 76 | callbacks = Callbacks(self.discord_client, self) 77 | 78 | self.add_event_callback( 79 | callbacks.message_callback, 80 | (nio.RoomMessageText, nio.RoomMessageMedia, nio.RoomMessageEmote), 81 | ) 82 | 83 | self.add_event_callback( 84 | callbacks.redaction_callback, nio.RedactionEvent 85 | ) 86 | 87 | self.add_ephemeral_callback( 88 | callbacks.typing_callback, nio.EphemeralEvent 89 | ) 90 | 91 | async def upload_emote(self, emote_id): 92 | if emote_id in self.uploaded_emotes.keys(): 93 | return self.uploaded_emotes[emote_id] 94 | 95 | emote_url = f"https://cdn.discordapp.com/emojis/{emote_id}" 96 | 97 | emote_file = f"/tmp/{str(uuid.uuid4())}" 98 | 99 | async with aiohttp.ClientSession() as session: 100 | async with session.get(emote_url) as resp: 101 | emote = await resp.read() 102 | content_type = resp.content_type 103 | 104 | async with aiofiles.open(emote_file, "wb") as f: 105 | await f.write(emote) 106 | 107 | async with aiofiles.open(emote_file, "rb") as f: 108 | resp, maybe_keys = await self.upload(f, content_type=content_type) 109 | 110 | await aiofiles.os.remove(emote_file) 111 | 112 | if type(resp) != nio.UploadResponse: 113 | self.logger.warning(f"Failed to upload emote {emote_id}") 114 | return 115 | 116 | self.uploaded_emotes[emote_id] = resp.content_uri 117 | 118 | return resp.content_uri 119 | 120 | async def get_fmt_body(self, body, emotes): 121 | replace_ = [ 122 | # Bold. 123 | ("**", "", ""), 124 | # Code blocks. 125 | ("```", "
", "
"), 126 | # Spoilers. 127 | ("||", "", ""), 128 | # Strikethrough. 129 | ("~~", "", ""), 130 | ] 131 | 132 | for replace in replace_: 133 | for i in range(1, body.count(replace[0]) + 1): 134 | if i % 2: 135 | body = body.replace(replace[0], replace[1], 1) 136 | else: 137 | body = body.replace(replace[0], replace[2], 1) 138 | 139 | for emote in emotes.keys(): 140 | emote_ = await self.upload_emote(emotes[emote]) 141 | if emote_: 142 | emote = f":{emote}:" 143 | body = body.replace( 144 | emote, 145 | f"""\"{emote}\"""", 147 | ) 148 | 149 | return body 150 | 151 | async def message_send( 152 | self, message, channel_id, emotes, reply_id=None, edit_id=None 153 | ): 154 | room_id = config["bridge"][str(channel_id)] 155 | 156 | content = { 157 | "body": message, 158 | "format": "org.matrix.custom.html", 159 | "formatted_body": await self.get_fmt_body(message, emotes), 160 | "msgtype": "m.text", 161 | } 162 | 163 | if reply_id: 164 | reply_event = await self.room_get_event(room_id, reply_id) 165 | reply_event = reply_event.event 166 | 167 | content = { 168 | **content, 169 | "m.relates_to": {"m.in_reply_to": {"event_id": reply_id}}, 170 | "formatted_body": f"""
\ 171 | In reply to\ 172 | {reply_event.sender}\ 173 |
{reply_event.body}
{content["formatted_body"]}""", 174 | } 175 | 176 | if edit_id: 177 | content = { 178 | **content, 179 | "body": f" * {content['body']}", 180 | "formatted_body": f" * {content['formatted_body']}", 181 | "m.relates_to": {"event_id": edit_id, "rel_type": "m.replace"}, 182 | "m.new_content": {**content}, 183 | } 184 | 185 | message = await self.room_send( 186 | room_id=room_id, message_type="m.room.message", content=content 187 | ) 188 | 189 | return message.event_id 190 | 191 | async def message_redact(self, message, channel_id): 192 | await self.room_redact( 193 | room_id=config["bridge"][str(channel_id)], event_id=message 194 | ) 195 | 196 | async def webhook_send( 197 | self, author, avatar, message, event_id, channel_id, embed=None 198 | ): 199 | channel = self.discord_client.channel_store[channel_id] 200 | 201 | hook_name = "matrix_bridge" 202 | 203 | hook = self.discord_client.webhook_cache.get(str(channel.id)) 204 | 205 | if not hook: 206 | hooks = await channel.webhooks() 207 | hook = discord.utils.get(hooks, name=hook_name) 208 | 209 | if not hook: 210 | hook = await channel.create_webhook(name=hook_name) 211 | 212 | self.discord_client.webhook_cache[str(channel.id)] = hook 213 | 214 | # Username must be between 1 and 80 characters in length, 215 | # 'wait=True' allows us to store the sent message. 216 | try: 217 | hook = await hook.send( 218 | username=author[:80], 219 | avatar_url=avatar, 220 | content=message, 221 | embed=embed, 222 | wait=True, 223 | ) 224 | 225 | message_store[event_id] = hook 226 | message_store[hook.id] = event_id 227 | except discord.errors.HTTPException as e: 228 | self.logger.warning(f"Failed to send message {event_id}: {e}") 229 | 230 | 231 | class DiscordClient(discord.ext.commands.Bot): 232 | def __init__(self, matrix_client, *args, **kwargs): 233 | super().__init__(*args, **kwargs) 234 | 235 | self.channel_store = {} 236 | 237 | self.webhook_cache = {} 238 | 239 | self.ready = asyncio.Event() 240 | 241 | self.add_cogs() 242 | 243 | self.matrix_client = matrix_client 244 | 245 | def add_cogs(self): 246 | cogs_dir = "./cogs" 247 | 248 | if not os.path.isdir(cogs_dir): 249 | return 250 | 251 | for cog in os.listdir(cogs_dir): 252 | if cog.endswith(".py"): 253 | cog = f"cogs.{cog[:-3]}" 254 | self.load_extension(cog) 255 | 256 | async def to_return(self, channel_id, message=None): 257 | await self.matrix_client.ready.wait() 258 | 259 | return str(channel_id) not in config["bridge"].keys() or ( 260 | message 261 | and message.webhook_id 262 | in [hook.id for hook in self.webhook_cache.values()] 263 | ) 264 | 265 | async def on_ready(self): 266 | for channel in config["bridge"].keys(): 267 | channel_ = self.get_channel(int(channel)) 268 | if not channel_: 269 | self.matrix_client.logger.warning(f"Failed to get channel for ID {channel}") 270 | continue 271 | self.channel_store[channel] = channel_ 272 | 273 | self.ready.set() 274 | 275 | async def on_message(self, message): 276 | # Process other stuff like cogs before ignoring the message. 277 | await self.process_commands(message) 278 | 279 | if await self.to_return(message.channel.id, message): 280 | return 281 | 282 | content = await self.process_message(message) 283 | 284 | matrix_message = await self.matrix_client.message_send( 285 | content[0], 286 | message.channel.id, 287 | reply_id=content[1], 288 | emotes=content[2], 289 | ) 290 | 291 | message_store[message.id] = matrix_message 292 | 293 | async def on_message_edit(self, before, after): 294 | if await self.to_return(after.channel.id, after): 295 | return 296 | 297 | content = await self.process_message(after) 298 | 299 | # Edit message only if it can be looked up in the cache. 300 | if before.id in message_store: 301 | await self.matrix_client.message_send( 302 | content[0], 303 | after.channel.id, 304 | edit_id=message_store[before.id], 305 | emotes=content[2], 306 | ) 307 | 308 | async def on_message_delete(self, message): 309 | # Delete message only if it can be looked up in the cache. 310 | if message.id in message_store: 311 | await self.matrix_client.message_redact( 312 | message_store[message.id], message.channel.id 313 | ) 314 | 315 | async def on_typing(self, channel, user, when): 316 | if await self.to_return(channel.id) or user == self.user: 317 | return 318 | 319 | # Send typing event 320 | await self.matrix_client.room_typing( 321 | config["bridge"][str(channel.id)], timeout=0 322 | ) 323 | 324 | async def process_message(self, message): 325 | content = message.clean_content 326 | 327 | regex = r"" 328 | emotes = {} 329 | 330 | # Store all emotes in a dict to upload and insert into formatted body. 331 | # { "emote_name": "emote_id" } 332 | for emote in re.findall(regex, content): 333 | emotes[emote[0]] = emote[1] 334 | 335 | # Get message reference for replies. 336 | replied_event = None 337 | if message.reference: 338 | replied_message = await message.channel.fetch_message( 339 | message.reference.message_id 340 | ) 341 | # Try to get the corresponding event from the message cache. 342 | try: 343 | replied_event = message_store[replied_message.id] 344 | except KeyError: 345 | pass 346 | 347 | # Replace emote IDs with names. 348 | content = re.sub(regex, r":\g<1>:", content) 349 | 350 | # Append attachments to message. 351 | for attachment in message.attachments: 352 | content += f"\n{attachment.url}" 353 | 354 | content = f"[{message.author.display_name}] {content}" 355 | 356 | return content, replied_event, emotes 357 | 358 | 359 | class Callbacks(object): 360 | def __init__(self, discord_client, matrix_client): 361 | self.discord_client = discord_client 362 | self.matrix_client = matrix_client 363 | 364 | def get_channel(self, room): 365 | channel_id = next( 366 | ( 367 | channel_id 368 | for channel_id, room_id in config["bridge"].items() 369 | if room_id == room.room_id 370 | ), 371 | None, 372 | ) 373 | 374 | return channel_id 375 | 376 | async def to_return(self, room, event): 377 | await self.matrix_client.discord_client.ready.wait() 378 | 379 | return ( 380 | room.room_id not in config["bridge"].values() 381 | or event.sender == self.matrix_client.user 382 | or not self.matrix_client.listen 383 | ) 384 | 385 | async def message_callback(self, room, event): 386 | message = event.body 387 | 388 | # Ignore messages having an empty body. 389 | if await self.to_return(room, event) or not message: 390 | return 391 | 392 | content_dict = event.source.get("content") 393 | 394 | # Get the corresponding Discord channel. 395 | channel_id = self.get_channel(room) 396 | 397 | if not channel_id: 398 | return 399 | 400 | author = room.user_name(event.sender) 401 | avatar = None 402 | 403 | homeserver = event.sender.split(":")[-1] 404 | url = "https://matrix.org/_matrix/media/r0/download" 405 | 406 | try: 407 | if content_dict["m.relates_to"]["rel_type"] == "m.replace": 408 | # Get the original message's event ID. 409 | edited_event = content_dict["m.relates_to"]["event_id"] 410 | edited_content = await self.process_message( 411 | content_dict["m.new_content"]["body"], channel_id 412 | ) 413 | 414 | # Get the corresponding Discord message. 415 | webhook_message = message_store[edited_event] 416 | 417 | try: 418 | await webhook_message.edit(content=edited_content) 419 | # Handle exception if edited message was deleted on Discord. 420 | except ( 421 | discord.errors.NotFound, 422 | discord.errors.HTTPException, 423 | ) as e: 424 | self.matrix_client.logger.warning( 425 | f"Failed to edit message {edited_event}: {e}" 426 | ) 427 | 428 | return 429 | except KeyError: 430 | pass 431 | 432 | try: 433 | if ( 434 | content_dict["m.relates_to"]["m.in_reply_to"]["event_id"] 435 | in message_store.values() 436 | ): 437 | # Remove the first occurence of our bot's username if replying. 438 | # > <@discordbridge:something.org> [discord user] 439 | message = message.replace(f"<{config['username']}>", "", 1) 440 | except KeyError: 441 | pass 442 | 443 | # _testuser waves_ (Italics) 444 | if content_dict["msgtype"] == "m.emote": 445 | message = f"_{author} {message}_" 446 | 447 | message = await self.process_message(message, channel_id) 448 | 449 | embed = None 450 | 451 | # Get attachments. 452 | try: 453 | attachment = event.url.split("/")[-1] 454 | # TODO: Fix URL for attachments forwarded from other rooms. 455 | attachment = f"{url}/{homeserver}/{attachment}" 456 | 457 | embed = discord.Embed(colour=discord.Colour.blue(), title=message) 458 | embed.set_image(url=attachment) 459 | 460 | # Send attachment URL in message along with embed, 461 | # Just in-case the attachment is not an image. 462 | message = attachment 463 | except AttributeError: 464 | pass 465 | 466 | # Get avatar. 467 | for user in room.users.values(): 468 | if user.user_id == event.sender: 469 | if user.avatar_url: 470 | avatar = user.avatar_url.split("/")[-1] 471 | avatar = f"{url}/{homeserver}/{avatar}" 472 | break 473 | 474 | await self.matrix_client.webhook_send( 475 | author, avatar, message, event.event_id, channel_id, embed=embed 476 | ) 477 | 478 | async def redaction_callback(self, room, event): 479 | if await self.to_return(room, event): 480 | return 481 | 482 | # Try to fetch the message from cache. 483 | try: 484 | message = message_store[event.redacts] 485 | await message.delete() 486 | # Handle exception if message was already deleted on Discord. 487 | except discord.errors.NotFound as e: 488 | self.matrix_client.logger.warning( 489 | f"Failed to delete message {event.event_id}: {e}" 490 | ) 491 | except KeyError: 492 | pass 493 | 494 | async def typing_callback(self, room, event): 495 | if ( 496 | not room.typing_users 497 | or ( 498 | len(room.typing_users) == 1 499 | and self.matrix_client.user in room.typing_users 500 | ) 501 | or room.room_id not in config["bridge"].values() 502 | ): 503 | return 504 | 505 | # Get the corresponding Discord channel. 506 | channel_id = self.get_channel(room) 507 | 508 | if not channel_id: 509 | return 510 | 511 | # Send typing event. 512 | async with self.discord_client.channel_store[channel_id].typing(): 513 | return 514 | 515 | async def process_message(self, message, channel_id): 516 | emotes = re.findall(r":(\w*):", message) 517 | mentions = re.findall(r"(@(\w*))", message) 518 | 519 | # Get the guild from channel ID. 520 | guild = self.discord_client.channel_store[channel_id].guild 521 | 522 | added_emotes = [] 523 | for emote in emotes: 524 | # Don't replace emote names with IDs multiple times. 525 | # :emote: becomes <:emote:emote_id> 526 | if emote not in added_emotes: 527 | added_emotes.append(emote) 528 | emote_ = discord.utils.get(guild.emojis, name=emote) 529 | if emote_: 530 | message = message.replace(f":{emote}:", str(emote_)) 531 | 532 | # mentions = [('@name', 'name'), ('@', '')] 533 | for mention in mentions: 534 | # Don't fetch member if mention is empty. 535 | # Single "@" without any name. 536 | if mention[1]: 537 | member = await guild.query_members(query=mention[1]) 538 | if member: 539 | # Get first result. 540 | message = message.replace(mention[0], member[0].mention) 541 | 542 | return message 543 | 544 | 545 | async def main(): 546 | logging.basicConfig( 547 | level=logging.INFO, 548 | format="%(asctime)s %(name)s:%(levelname)s: %(message)s", 549 | datefmt="%Y-%m-%d %H:%M:%S", 550 | handlers=[ 551 | logging.FileHandler("bridge.log"), 552 | logging.StreamHandler(), 553 | ], 554 | ) 555 | 556 | retry = 2 557 | 558 | matrix_client = MatrixClient(config["homeserver"], config["username"]) 559 | 560 | while True: 561 | resp = await matrix_client.login(config["password"]) 562 | 563 | if type(resp) == nio.LoginError: 564 | matrix_client.logger.error(f"Failed to login: {resp}") 565 | return False 566 | 567 | # Login successful. 568 | matrix_client.logger.info(resp) 569 | 570 | try: 571 | await matrix_client.sync(full_state=True) 572 | except Exception: 573 | matrix_client.logger.exception("Initial sync failed!") 574 | return False 575 | 576 | try: 577 | matrix_client.ready.set() 578 | matrix_client.listen = True 579 | 580 | matrix_client.logger.info("Clients ready!") 581 | 582 | await matrix_client.sync_forever(timeout=30000, full_state=True) 583 | except Exception: 584 | matrix_client.logger.exception( 585 | f"Unknown exception occured, retrying in {retry} seconds..." 586 | ) 587 | 588 | # Clear "ready" status. 589 | matrix_client.ready.clear() 590 | 591 | await matrix_client.close() 592 | await asyncio.sleep(retry) 593 | 594 | matrix_client.listen = False 595 | finally: 596 | if matrix_client.listen: 597 | await matrix_client.close() 598 | return False 599 | 600 | 601 | if __name__ == "__main__": 602 | asyncio.run(main()) 603 | -------------------------------------------------------------------------------- /appservice/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import os 5 | import re 6 | import sys 7 | import threading 8 | import urllib.parse 9 | from typing import Dict, List, Tuple 10 | 11 | import markdown 12 | import urllib3 13 | 14 | import discord 15 | import matrix 16 | from appservice import AppService 17 | from cache import Cache 18 | from db import DataBase 19 | from errors import RequestError 20 | from gateway import Gateway 21 | from misc import dict_cls, except_deleted, hash_str 22 | 23 | 24 | class MatrixClient(AppService): 25 | def __init__(self, config: dict, http: urllib3.PoolManager) -> None: 26 | super().__init__(config, http) 27 | 28 | self.db = DataBase(config["database"]) 29 | self.discord = DiscordClient(self, config, http) 30 | self.format = "_discord_" # "{@,#}_discord_1234:localhost" 31 | self.id_regex = "[0-9]+" # Snowflakes may have variable length 32 | 33 | # TODO Find a cleaner way to use these keys. 34 | for k in ("m_emotes", "m_members", "m_messages"): 35 | Cache.cache[k] = {} 36 | 37 | def handle_bridge(self, message: matrix.Event) -> None: 38 | # Ignore events that aren't for us. 39 | if message.sender.split(":")[ 40 | -1 41 | ] != self.server_name or not message.body.startswith("!bridge"): 42 | return 43 | 44 | # Get the channel ID. 45 | try: 46 | channel = message.body.split()[1] 47 | except IndexError: 48 | return 49 | 50 | # Check if the given channel is valid. 51 | try: 52 | channel = self.discord.get_channel(channel) 53 | except RequestError as e: 54 | # The channel can be invalid or we may not have permissions. 55 | self.logger.warning(f"Failed to fetch channel {channel}: {e}") 56 | return 57 | 58 | if ( 59 | channel.type != discord.ChannelType.GUILD_TEXT 60 | or channel.id in self.db.list_channels() 61 | ): 62 | return 63 | 64 | self.logger.info(f"Creating bridged room for channel {channel.id}.") 65 | 66 | self.create_room(channel, message.sender) 67 | 68 | def on_member(self, event: matrix.Event) -> None: 69 | with Cache.lock: 70 | # Just lazily clear the whole member cache on 71 | # membership update events. 72 | if event.room_id in Cache.cache["m_members"]: 73 | self.logger.info( 74 | f"Clearing member cache for room '{event.room_id}'." 75 | ) 76 | del Cache.cache["m_members"][event.room_id] 77 | 78 | if ( 79 | event.sender.split(":")[-1] != self.server_name 80 | or event.state_key != self.user_id 81 | or not event.is_direct 82 | ): 83 | return 84 | 85 | # Join the direct message room. 86 | self.logger.info(f"Joining direct message room '{event.room_id}'.") 87 | self.join_room(event.room_id) 88 | 89 | def on_message(self, message: matrix.Event) -> None: 90 | if ( 91 | message.sender.startswith((f"@{self.format}", self.user_id)) 92 | or not message.body 93 | ): 94 | return 95 | 96 | # Handle bridging commands. 97 | self.handle_bridge(message) 98 | 99 | channel_id = self.db.get_channel(message.room_id) 100 | 101 | if not channel_id: 102 | return 103 | 104 | author = self.get_members(message.room_id)[message.sender] 105 | 106 | webhook = self.discord.get_webhook( 107 | channel_id, self.discord.webhook_name 108 | ) 109 | 110 | if message.relates_to and message.reltype == "m.replace": 111 | with Cache.lock: 112 | message_id = Cache.cache["m_messages"].get(message.relates_to) 113 | 114 | # TODO validate if the original author sent the edit. 115 | 116 | if not message_id or not message.new_body: 117 | return 118 | 119 | message.new_body = self.process_message(message) 120 | 121 | except_deleted(self.discord.edit_webhook)( 122 | message.new_body, message_id, webhook 123 | ) 124 | else: 125 | message.body = ( 126 | f"`{message.body}`: {self.mxc_url(message.attachment)}" 127 | if message.attachment 128 | else self.process_message(message) 129 | ) 130 | 131 | message_id = self.discord.send_webhook( 132 | webhook, 133 | self.mxc_url(author.avatar_url) if author.avatar_url else None, 134 | message.body, 135 | author.display_name if author.display_name else message.sender, 136 | ).id 137 | 138 | with Cache.lock: 139 | Cache.cache["m_messages"][message.id] = message_id 140 | 141 | def on_redaction(self, event: matrix.Event) -> None: 142 | with Cache.lock: 143 | message_id = Cache.cache["m_messages"].get(event.redacts) 144 | 145 | if not message_id: 146 | return 147 | 148 | webhook = self.discord.get_webhook( 149 | self.db.get_channel(event.room_id), self.discord.webhook_name 150 | ) 151 | 152 | except_deleted(self.discord.delete_webhook)(message_id, webhook) 153 | 154 | with Cache.lock: 155 | del Cache.cache["m_messages"][event.redacts] 156 | 157 | def get_members(self, room_id: str) -> Dict[str, matrix.User]: 158 | with Cache.lock: 159 | cached = Cache.cache["m_members"].get(room_id) 160 | 161 | if cached: 162 | return cached 163 | 164 | resp = self.send("GET", f"/rooms/{room_id}/joined_members") 165 | 166 | joined = resp["joined"] 167 | 168 | for k, v in joined.items(): 169 | joined[k] = dict_cls(v, matrix.User) 170 | 171 | with Cache.lock: 172 | Cache.cache["m_members"][room_id] = joined 173 | 174 | return joined 175 | 176 | def create_room(self, channel: discord.Channel, sender: str) -> None: 177 | """ 178 | Create a bridged room and invite the person who invoked the command. 179 | """ 180 | 181 | content = { 182 | "room_alias_name": f"{self.format}{channel.id}", 183 | "name": channel.name, 184 | "topic": channel.topic if channel.topic else channel.name, 185 | "visibility": "private", 186 | "invite": [sender], 187 | "creation_content": {"m.federate": True}, 188 | "initial_state": [ 189 | { 190 | "type": "m.room.join_rules", 191 | "content": {"join_rule": "public"}, 192 | }, 193 | { 194 | "type": "m.room.history_visibility", 195 | "content": {"history_visibility": "shared"}, 196 | }, 197 | ], 198 | "power_level_content_override": { 199 | "users": {sender: 100, self.user_id: 100} 200 | }, 201 | } 202 | 203 | resp = self.send("POST", "/createRoom", content) 204 | 205 | self.db.add_room(resp["room_id"], channel.id) 206 | 207 | def create_message_event( 208 | self, 209 | message: str, 210 | emotes: dict, 211 | edit: str = "", 212 | reference: discord.Message = None, 213 | ) -> dict: 214 | content = { 215 | "body": message, 216 | "msgtype": "m.text", 217 | } 218 | 219 | fmt = self.get_fmt(message, emotes) 220 | 221 | if fmt != message: 222 | content = { 223 | **content, 224 | "format": "org.matrix.custom.html", 225 | "formatted_body": fmt, 226 | } 227 | 228 | ref_id = None 229 | 230 | if reference: 231 | # Reply to a Discord message. 232 | with Cache.lock: 233 | ref_id = Cache.cache["d_messages"].get(reference.id) 234 | 235 | # Reply to a Matrix message. (maybe) 236 | if not ref_id: 237 | with Cache.lock: 238 | ref_id = [ 239 | k 240 | for k, v in Cache.cache["m_messages"].items() 241 | if v == reference.id 242 | ] 243 | ref_id = next(iter(ref_id), "") 244 | 245 | if ref_id: 246 | event = except_deleted(self.get_event)( 247 | ref_id, 248 | self.get_room_id(self.discord.matrixify(reference.channel_id)), 249 | ) 250 | if event: 251 | # Content with the reply fallbacks stripped. 252 | tmp = "" 253 | # We don't want to strip lines starting with "> " after 254 | # encountering a regular line, so we use this variable. 255 | got_fallback = True 256 | for line in event.body.split("\n"): 257 | if not line.startswith("> "): 258 | got_fallback = False 259 | if not got_fallback: 260 | tmp += line 261 | 262 | event.body = tmp 263 | event.formatted_body = ( 264 | # re.DOTALL allows the match to span newlines. 265 | re.sub( 266 | "", 267 | "", 268 | event.formatted_body, 269 | flags=re.DOTALL, 270 | ) 271 | if event.formatted_body 272 | else event.body 273 | ) 274 | 275 | content = { 276 | **content, 277 | "body": ( 278 | f"> <{event.sender}> {event.body}\n{content['body']}" 279 | ), 280 | "m.relates_to": {"m.in_reply_to": {"event_id": event.id}}, 281 | "format": "org.matrix.custom.html", 282 | "formatted_body": f"""
\ 283 | In reply to\ 284 | {event.sender}\ 285 |
{event.formatted_body if event.formatted_body else event.body}\ 286 |
\ 287 | {content.get("formatted_body", content['body'])}""", 288 | } 289 | 290 | if edit: 291 | content = { 292 | **content, 293 | "body": f" * {content['body']}", 294 | "formatted_body": f" * {content.get('formatted_body', content['body'])}", 295 | "m.relates_to": {"event_id": edit, "rel_type": "m.replace"}, 296 | "m.new_content": {**content}, 297 | } 298 | 299 | return content 300 | 301 | def get_fmt(self, message: str, emotes: dict) -> str: 302 | message = ( 303 | markdown.markdown(message) 304 | .replace("

", "") 305 | .replace("

", "") 306 | .replace("\n", "
") 307 | ) 308 | 309 | # Upload emotes in multiple threads so that we don't 310 | # block the Discord bot for too long. 311 | upload_threads = [ 312 | threading.Thread( 313 | target=self.upload_emote, args=(emote, emotes[emote]) 314 | ) 315 | for emote in emotes 316 | ] 317 | 318 | # Acquire the lock before starting the threads to avoid resource 319 | # contention by tens of threads at once. 320 | with Cache.lock: 321 | for thread in upload_threads: 322 | thread.start() 323 | for thread in upload_threads: 324 | thread.join() 325 | 326 | with Cache.lock: 327 | for emote in emotes: 328 | emote_ = Cache.cache["m_emotes"].get(emote) 329 | 330 | if emote_: 331 | emote = f":{emote}:" 332 | message = message.replace( 333 | emote, 334 | f"""\"{emote}\"""", 336 | ) 337 | 338 | return message 339 | 340 | def mention_regex(self, encode: bool, id_as_group: bool) -> str: 341 | mention = "@" 342 | colon = ":" 343 | snowflake = self.id_regex 344 | 345 | if encode: 346 | mention = urllib.parse.quote(mention) 347 | colon = urllib.parse.quote(colon) 348 | 349 | if id_as_group: 350 | snowflake = f"({snowflake})" 351 | 352 | hashed = f"(?:-{snowflake})?" 353 | 354 | return f"{mention}{self.format}{snowflake}{hashed}{colon}{re.escape(self.server_name)}" 355 | 356 | def process_message(self, event: matrix.Event) -> str: 357 | message = event.new_body if event.new_body else event.body 358 | 359 | emotes = re.findall(r":(\w*):", message) 360 | 361 | mentions = list( 362 | re.finditer( 363 | self.mention_regex(encode=False, id_as_group=True), 364 | event.formatted_body, 365 | ) 366 | ) 367 | # For clients that properly encode mentions. 368 | # 'https://matrix.to/#/%40_discord_...%3Adomain.tld' 369 | mentions.extend( 370 | re.finditer( 371 | self.mention_regex(encode=True, id_as_group=True), 372 | event.formatted_body, 373 | ) 374 | ) 375 | 376 | with Cache.lock: 377 | for emote in set(emotes): 378 | emote_ = Cache.cache["d_emotes"].get(emote) 379 | if emote_: 380 | message = message.replace(f":{emote}:", emote_) 381 | 382 | for mention in set(mentions): 383 | # Unquote just in-case we matched an encoded username. 384 | username = self.db.fetch_user( 385 | urllib.parse.unquote(mention.group(0)) 386 | ).get("username") 387 | if username: 388 | if mention.group(2): 389 | # Replace mention with plain text for hashed users (webhooks) 390 | message = message.replace(mention.group(0), f"@{username}") 391 | else: 392 | # Replace the 'mention' so that the user is tagged 393 | # in the case of replies aswell. 394 | # '> <@_discord_1234:localhost> Message' 395 | for replace in (mention.group(0), username): 396 | message = message.replace( 397 | replace, f"<@{mention.group(1)}>" 398 | ) 399 | 400 | # We trim the message later as emotes take up extra characters too. 401 | return message[: discord.MESSAGE_LIMIT] 402 | 403 | def upload_emote(self, emote_name: str, emote_id: str) -> None: 404 | # There won't be a race condition here, since only a unique 405 | # set of emotes are uploaded at a time. 406 | if emote_name in Cache.cache["m_emotes"]: 407 | return 408 | 409 | emote_url = f"{discord.CDN_URL}/emojis/{emote_id}" 410 | 411 | # We don't want the message to be dropped entirely if an emote 412 | # fails to upload for some reason. 413 | try: 414 | # TODO This is not thread safe, but we're protected by the GIL. 415 | Cache.cache["m_emotes"][emote_name] = self.upload(emote_url) 416 | except RequestError as e: 417 | self.logger.warning(f"Failed to upload emote {emote_id}: {e}") 418 | 419 | def register(self, mxid: str) -> None: 420 | """ 421 | Register a dummy user on the homeserver. 422 | """ 423 | 424 | content = { 425 | "type": "m.login.application_service", 426 | # "@test:localhost" -> "test" (Can't register with a full mxid.) 427 | "username": mxid[1:].split(":")[0], 428 | } 429 | 430 | resp = self.send("POST", "/register", content) 431 | 432 | self.db.add_user(resp["user_id"]) 433 | 434 | def set_avatar(self, avatar_url: str, mxid: str) -> None: 435 | avatar_uri = self.upload(avatar_url) 436 | 437 | self.send( 438 | "PUT", 439 | f"/profile/{mxid}/avatar_url", 440 | {"avatar_url": avatar_uri}, 441 | params={"user_id": mxid}, 442 | ) 443 | 444 | self.db.add_avatar(avatar_url, mxid) 445 | 446 | def set_nick(self, username: str, mxid: str) -> None: 447 | self.send( 448 | "PUT", 449 | f"/profile/{mxid}/displayname", 450 | {"displayname": username}, 451 | params={"user_id": mxid}, 452 | ) 453 | 454 | self.db.add_username(username, mxid) 455 | 456 | 457 | class DiscordClient(Gateway): 458 | def __init__( 459 | self, appservice: MatrixClient, config: dict, http: urllib3.PoolManager 460 | ) -> None: 461 | super().__init__(http, config["discord_token"]) 462 | 463 | self.app = appservice 464 | self.webhook_name = "matrix_bridge" 465 | 466 | # TODO Find a cleaner way to use these keys. 467 | for k in ("d_emotes", "d_messages", "d_webhooks"): 468 | Cache.cache[k] = {} 469 | 470 | def to_return(self, message: discord.Message) -> bool: 471 | with Cache.lock: 472 | hook_ids = [hook.id for hook in Cache.cache["d_webhooks"].values()] 473 | 474 | return ( 475 | message.channel_id not in self.app.db.list_channels() 476 | or not message.author # Embeds can be weird sometimes. 477 | or message.webhook_id in hook_ids 478 | ) 479 | 480 | def matrixify(self, id: str, user: bool = False, hashed: str = "") -> str: 481 | return ( 482 | f"{'@' if user else '#'}{self.app.format}" 483 | f"{id}{'-' + hashed if hashed else ''}:" 484 | f"{self.app.server_name}" 485 | ) 486 | 487 | def sync_profile(self, user: discord.User, hashed: str = "") -> None: 488 | """ 489 | Sync the avatar and username for a puppeted user. 490 | """ 491 | 492 | mxid = self.matrixify(user.id, user=True, hashed=hashed) 493 | 494 | profile = self.app.db.fetch_user(mxid) 495 | 496 | # User doesn't exist. 497 | if not profile: 498 | return 499 | 500 | username = f"{user.username}#{user.discriminator}" 501 | 502 | if user.avatar_url != profile["avatar_url"]: 503 | self.logger.info(f"Updating avatar for Discord user '{user.id}'") 504 | self.app.set_avatar(user.avatar_url, mxid) 505 | if username != profile["username"]: 506 | self.logger.info(f"Updating username for Discord user '{user.id}'") 507 | self.app.set_nick(username, mxid) 508 | 509 | def wrap(self, message: discord.Message) -> Tuple[str, str]: 510 | """ 511 | Get the room ID and the puppet's mxid for a given channel ID and a 512 | Discord user. 513 | """ 514 | 515 | hashed = "" 516 | if message.webhook_id and message.webhook_id != message.application_id: 517 | hashed = str(hash_str(message.author.username)) 518 | 519 | mxid = self.matrixify(message.author.id, user=True, hashed=hashed) 520 | room_id = self.app.get_room_id(self.matrixify(message.channel_id)) 521 | 522 | if not self.app.db.fetch_user(mxid): 523 | self.logger.info( 524 | f"Creating dummy user for Discord user {message.author.id}." 525 | ) 526 | self.app.register(mxid) 527 | 528 | self.app.set_nick( 529 | f"{message.author.username}#" 530 | f"{message.author.discriminator}", 531 | mxid, 532 | ) 533 | 534 | if message.author.avatar_url: 535 | self.app.set_avatar(message.author.avatar_url, mxid) 536 | 537 | if mxid not in self.app.get_members(room_id): 538 | self.logger.info(f"Inviting user '{mxid}' to room '{room_id}'.") 539 | 540 | self.app.send_invite(room_id, mxid) 541 | self.app.join_room(room_id, mxid) 542 | 543 | if message.webhook_id: 544 | # Sync webhooks here as they can't be accessed like guild members. 545 | self.sync_profile(message.author, hashed=hashed) 546 | 547 | return mxid, room_id 548 | 549 | def cache_emotes(self, emotes: List[discord.Emote]): 550 | # TODO maybe "namespace" emotes by guild in the cache ? 551 | with Cache.lock: 552 | for emote in emotes: 553 | Cache.cache["d_emotes"][emote.name] = ( 554 | f"<{'a' if emote.animated else ''}:" 555 | f"{emote.name}:{emote.id}>" 556 | ) 557 | 558 | def on_guild_create(self, guild: discord.Guild) -> None: 559 | for member in guild.members: 560 | self.sync_profile(member) 561 | 562 | self.cache_emotes(guild.emojis) 563 | 564 | def on_guild_emojis_update( 565 | self, update: discord.GuildEmojisUpdate 566 | ) -> None: 567 | self.cache_emotes(update.emojis) 568 | 569 | def on_guild_member_update( 570 | self, update: discord.GuildMemberUpdate 571 | ) -> None: 572 | self.sync_profile(update.user) 573 | 574 | def on_message_create(self, message: discord.Message) -> None: 575 | if self.to_return(message): 576 | return 577 | 578 | mxid, room_id = self.wrap(message) 579 | 580 | content_, emotes = self.process_message(message) 581 | 582 | content = self.app.create_message_event( 583 | content_, emotes, reference=message.referenced_message 584 | ) 585 | 586 | with Cache.lock: 587 | Cache.cache["d_messages"][message.id] = self.app.send_message( 588 | room_id, content, mxid 589 | ) 590 | 591 | def on_message_delete(self, message: discord.Message) -> None: 592 | with Cache.lock: 593 | event_id = Cache.cache["d_messages"].get(message.id) 594 | 595 | if not event_id: 596 | return 597 | 598 | room_id = self.app.get_room_id(self.matrixify(message.channel_id)) 599 | event = except_deleted(self.app.get_event)(event_id, room_id) 600 | 601 | if event: 602 | self.app.redact(event.id, event.room_id, event.sender) 603 | 604 | with Cache.lock: 605 | del Cache.cache["d_messages"][message.id] 606 | 607 | def on_message_update(self, message: discord.Message) -> None: 608 | if self.to_return(message): 609 | return 610 | 611 | with Cache.lock: 612 | event_id = Cache.cache["d_messages"].get(message.id) 613 | 614 | if not event_id: 615 | return 616 | 617 | room_id = self.app.get_room_id(self.matrixify(message.channel_id)) 618 | mxid = self.matrixify(message.author.id, user=True) 619 | 620 | # It is possible that a webhook edit's it's own old message 621 | # after changing it's name, hence we generate a new mxid from 622 | # the hashed username, but that mxid hasn't been registered before, 623 | # so the request fails with: 624 | # M_FORBIDDEN: Application service has not registered this user 625 | if not self.app.db.fetch_user(mxid): 626 | return 627 | 628 | content_, emotes = self.process_message(message) 629 | 630 | content = self.app.create_message_event( 631 | content_, emotes, edit=event_id 632 | ) 633 | 634 | self.app.send_message(room_id, content, mxid) 635 | 636 | def on_typing_start(self, typing: discord.Typing) -> None: 637 | if typing.channel_id not in self.app.db.list_channels(): 638 | return 639 | 640 | mxid = self.matrixify(typing.user_id, user=True) 641 | room_id = self.app.get_room_id(self.matrixify(typing.channel_id)) 642 | 643 | if mxid not in self.app.get_members(room_id): 644 | return 645 | 646 | self.app.send_typing(room_id, mxid) 647 | 648 | def get_webhook(self, channel_id: str, name: str) -> discord.Webhook: 649 | """ 650 | Get the webhook object for the first webhook that matches the specified 651 | name in a given channel, create the webhook if it doesn't exist. 652 | """ 653 | 654 | # Check the cache first. 655 | with Cache.lock: 656 | webhook = Cache.cache["d_webhooks"].get(channel_id) 657 | 658 | if webhook: 659 | return webhook 660 | 661 | webhooks = self.send("GET", f"/channels/{channel_id}/webhooks") 662 | webhook = next( 663 | ( 664 | dict_cls(webhook, discord.Webhook) 665 | for webhook in webhooks 666 | if webhook["name"] == name 667 | ), 668 | None, 669 | ) 670 | 671 | if not webhook: 672 | webhook = self.create_webhook(channel_id, name) 673 | 674 | with Cache.lock: 675 | Cache.cache["d_webhooks"][channel_id] = webhook 676 | 677 | return webhook 678 | 679 | def process_message(self, message: discord.Message) -> Tuple[str, Dict]: 680 | content = message.content 681 | emotes = {} 682 | regex = r"" 683 | 684 | # Mentions can either be in the form of `<@1234>` or `<@!1234>`. 685 | for member in message.mentions: 686 | for char in ("", "!"): 687 | content = content.replace( 688 | f"<@{char}{member.id}>", f"@{member.username}" 689 | ) 690 | 691 | # Replace channel IDs with names. 692 | channels = re.findall("<#([0-9]+)>", content) 693 | if channels: 694 | if not message.guild_id: 695 | self.logger.warning( 696 | f"Message '{message.id}' in channel '{message.channel_id}' does not have a guild_id!" 697 | ) 698 | else: 699 | discord_channels = self.get_channels(message.guild_id) 700 | for channel in channels: 701 | discord_channel = discord_channels.get(channel) 702 | name = ( 703 | discord_channel.name 704 | if discord_channel 705 | else "deleted-channel" 706 | ) 707 | content = content.replace(f"<#{channel}>", f"#{name}") 708 | 709 | # { "emote_name": "emote_id" } 710 | for emote in re.findall(regex, content): 711 | emotes[emote[0]] = emote[1] 712 | 713 | # Replace emote IDs with names. 714 | content = re.sub(regex, r":\g<1>:", content) 715 | 716 | # Append attachments to message. 717 | for attachment in message.attachments: 718 | content += f"\n{attachment['url']}" 719 | 720 | # Append stickers to message. 721 | for sticker in message.stickers: 722 | if sticker.format_type != 3: # 3 == Lottie format. 723 | content += f"\n{discord.CDN_URL}/stickers/{sticker.id}.png" 724 | 725 | return content, emotes 726 | 727 | 728 | def config_gen(basedir: str, config_file: str) -> dict: 729 | config_file = f"{basedir}/{config_file}" 730 | 731 | config_dict = { 732 | "as_token": "my-secret-as-token", 733 | "hs_token": "my-secret-hs-token", 734 | "user_id": "appservice-discord", 735 | "homeserver": "http://127.0.0.1:8008", 736 | "server_name": "localhost", 737 | "discord_token": "my-secret-discord-token", 738 | "port": 5000, 739 | "database": f"{basedir}/bridge.db", 740 | } 741 | 742 | if not os.path.exists(config_file): 743 | with open(config_file, "w") as f: 744 | json.dump(config_dict, f, indent=4) 745 | print(f"Configuration dumped to '{config_file}'") 746 | sys.exit() 747 | 748 | with open(config_file, "r") as f: 749 | return json.loads(f.read()) 750 | 751 | 752 | def excepthook(exc_type, exc_value, exc_traceback): 753 | if issubclass(exc_type, KeyboardInterrupt): 754 | sys.__excepthook__(exc_type, exc_value, exc_traceback) 755 | return 756 | 757 | logging.critical( 758 | "Unknown exception:", exc_info=(exc_type, exc_value, exc_traceback) 759 | ) 760 | 761 | 762 | def main() -> None: 763 | try: 764 | basedir = sys.argv[1] 765 | if not os.path.exists(basedir): 766 | print(f"Path '{basedir}' does not exist!") 767 | sys.exit(1) 768 | basedir = os.path.abspath(basedir) 769 | except IndexError: 770 | basedir = os.getcwd() 771 | 772 | config = config_gen(basedir, "appservice.json") 773 | 774 | logging.basicConfig( 775 | level=logging.INFO, 776 | format="%(asctime)s %(name)s:%(levelname)s:%(message)s", 777 | datefmt="%Y-%m-%d %H:%M:%S", 778 | handlers=[ 779 | logging.FileHandler(f"{basedir}/appservice.log"), 780 | ], 781 | ) 782 | 783 | sys.excepthook = excepthook 784 | 785 | app = MatrixClient(config, urllib3.PoolManager(maxsize=10)) 786 | 787 | # Start the bottle app in a separate thread. 788 | app_thread = threading.Thread( 789 | target=app.run, kwargs={"port": int(config["port"])}, daemon=True 790 | ) 791 | app_thread.start() 792 | 793 | try: 794 | asyncio.run(app.discord.run()) 795 | except KeyboardInterrupt: 796 | sys.exit() 797 | 798 | 799 | if __name__ == "__main__": 800 | main() 801 | --------------------------------------------------------------------------------