diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..68dc435
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,10 @@
+# Unix-style newlines with a newline ending every file
+[*]
+charset = utf-8
+indent_style = tab
+end_of_line = lf
+insert_final_newline = true
+
+[*.py]
+indent_size = 4
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9f60581
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,132 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# Auto generated version file
+src/aioappsrv/__version__.py
diff --git a/README.md b/README.md
index f0d19f9..0399257 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,111 @@
-# matrix-discord-bridge
+## Installation
-A simple bridge between Matrix and Discord written in Python.
+`pip install -r requirements.txt`
-This repository contains two bridges:
+## Usage
-* A [puppeting appservice](appservice): The puppeting bridge written with minimal dependencies. Running this requires a self-hosted homeserver.
+* Run `main.py` to generate `appservice.json`
-* 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.
+* Edit `appservice.json`:
-Check their READMEs for specific information.
+```
+{
+ "as_token": "my-secret-as-token",
+ "hs_token": "my-secret-hs-token",
+ "user_id": "appservice-discord",
+ "homeserver": "http://127.0.0.1:8008",
+ "server_name": "localhost",
+ "discord_token": "my-secret-discord-token",
+ "port": 5000,
+ "database": "/path/to/bridge.db"
+}
+```
-![Demo](demo.png)
+`as_token`: The token sent by the appservice to the homeserver with events.
-## What Works
+`hs_token`: The token sent by the homeserver to the appservice with events.
-- [x] Puppeting (Appservice only, regular bridge only uses webhooks on Discord.)
-- [x] Attachments (Converted to URLs.)
-- [x] Typing Indicators (Per-user indicators on Appservice, otherwise sent as bot user.)
-- [x] Message redaction
-- [x] Replies
-- [x] Bridging multiple channels
-- [x] Discord emojis displayed as inline images
-- [x] Sending Discord emotes from Matrix (`:emote_name:`)
-- [x] Mentioning Discord users via partial username (`@partialname`)
+`user_id`: The username of the appservice user, it should match the `sender_localpart` in `appservice.yaml`.
-## TODO
+`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`.
-- [ ] Handle cases where the webhook is messed with on the Discord side (Deleted/Edited by someone other than the bot).
-- [ ] Use embeds on Discord side for replies.
-- [ ] Unbridging.
+`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`.
+
+`discord_token`: The Discord bot's token.
+
+`port`: The port where `bottle` will listen for events.
+
+`database`: Full path to the bridge's database.
+
+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).
+
+* Create `appservice.yaml` and add it to your homeserver:
+
+```
+id: "discord"
+url: "http://127.0.0.1:5000"
+as_token: "my-secret-as-token"
+hs_token: "my-secret-hs-token"
+sender_localpart: "appservice-discord"
+namespaces:
+ users:
+ - exclusive: true
+ regex: "@_discord.*"
+ - exclusive: true
+ regex: "@appservice-discord"
+ aliases:
+ - exclusive: true
+ regex: "#_discord.*"
+ rooms: []
+```
+
+The following lines should be added to the homeserver configuration. The full path to `appservice.yaml` might be required:
+
+* `synapse`:
+
+```
+# A list of application service config files to use
+#
+app_service_config_files:
+ - appservice.yaml
+```
+
+* `dendrite`:
+
+```
+app_service_api:
+ internal_api:
+ # ...
+ database:
+ # ...
+ config_files: [appservice.yaml]
+```
+
+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.
+
+Eg. Running `python3 main.py /path/to/my/dir` will store the database and logs in `/path/to/my/dir`.
+`$PWD` is used by default if no path is specified.
+
+After setting up the bridge, send a direct message to `@appservice-discord:domain.tld` containing the channel ID to be bridged (`!bridge 123456`).
+
+This bridge is written with:
+
+* `bottle`: Receiving events from the homeserver.
+* `urllib3`: Sending requests, thread safety.
+* `websockets`: Connecting to Discord. (Big thanks to an anonymous person "nesslersreagent" for figuring out the initial connection mess.)
+
+## NOTES
+
+* A basic sqlite database is used for keeping track of bridged rooms.
+
+* 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.
+
+* Logs are saved to the `appservice.log` file in `$PWD` or the specified directory.
+
+* 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.
+
+* It is not possible to add "normal" Discord bot functionality like commands as this bridge does not use `discord.py`.
+
+* [Privileged Intents](https://discordpy.readthedocs.io/en/latest/intents.html#privileged-intents) for members and presence must be enabled for your Discord bot.
+
+* 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.
diff --git a/appservice/README.md b/appservice/README.md
deleted file mode 100644
index 0399257..0000000
--- a/appservice/README.md
+++ /dev/null
@@ -1,111 +0,0 @@
-## Installation
-
-`pip install -r requirements.txt`
-
-## Usage
-
-* Run `main.py` to generate `appservice.json`
-
-* Edit `appservice.json`:
-
-```
-{
- "as_token": "my-secret-as-token",
- "hs_token": "my-secret-hs-token",
- "user_id": "appservice-discord",
- "homeserver": "http://127.0.0.1:8008",
- "server_name": "localhost",
- "discord_token": "my-secret-discord-token",
- "port": 5000,
- "database": "/path/to/bridge.db"
-}
-```
-
-`as_token`: The token sent by the appservice to the homeserver with events.
-
-`hs_token`: The token sent by the homeserver to the appservice with events.
-
-`user_id`: The username of the appservice user, it should match the `sender_localpart` in `appservice.yaml`.
-
-`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`.
-
-`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`.
-
-`discord_token`: The Discord bot's token.
-
-`port`: The port where `bottle` will listen for events.
-
-`database`: Full path to the bridge's database.
-
-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).
-
-* Create `appservice.yaml` and add it to your homeserver:
-
-```
-id: "discord"
-url: "http://127.0.0.1:5000"
-as_token: "my-secret-as-token"
-hs_token: "my-secret-hs-token"
-sender_localpart: "appservice-discord"
-namespaces:
- users:
- - exclusive: true
- regex: "@_discord.*"
- - exclusive: true
- regex: "@appservice-discord"
- aliases:
- - exclusive: true
- regex: "#_discord.*"
- rooms: []
-```
-
-The following lines should be added to the homeserver configuration. The full path to `appservice.yaml` might be required:
-
-* `synapse`:
-
-```
-# A list of application service config files to use
-#
-app_service_config_files:
- - appservice.yaml
-```
-
-* `dendrite`:
-
-```
-app_service_api:
- internal_api:
- # ...
- database:
- # ...
- config_files: [appservice.yaml]
-```
-
-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.
-
-Eg. Running `python3 main.py /path/to/my/dir` will store the database and logs in `/path/to/my/dir`.
-`$PWD` is used by default if no path is specified.
-
-After setting up the bridge, send a direct message to `@appservice-discord:domain.tld` containing the channel ID to be bridged (`!bridge 123456`).
-
-This bridge is written with:
-
-* `bottle`: Receiving events from the homeserver.
-* `urllib3`: Sending requests, thread safety.
-* `websockets`: Connecting to Discord. (Big thanks to an anonymous person "nesslersreagent" for figuring out the initial connection mess.)
-
-## NOTES
-
-* A basic sqlite database is used for keeping track of bridged rooms.
-
-* 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.
-
-* Logs are saved to the `appservice.log` file in `$PWD` or the specified directory.
-
-* 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.
-
-* It is not possible to add "normal" Discord bot functionality like commands as this bridge does not use `discord.py`.
-
-* [Privileged Intents](https://discordpy.readthedocs.io/en/latest/intents.html#privileged-intents) for members and presence must be enabled for your Discord bot.
-
-* 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.
diff --git a/appservice/requirements.txt b/appservice/requirements.txt
deleted file mode 100644
index 0a8c686..0000000
--- a/appservice/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-bottle
-markdown
-urllib3
-websockets
diff --git a/bridge/README.md b/bridge/README.md
deleted file mode 100644
index f6fe32c..0000000
--- a/bridge/README.md
+++ /dev/null
@@ -1,31 +0,0 @@
-## Installation
-
-`pip install -r requirements.txt`
-
-## Usage
-
-* Run `bridge.py` to generate `config.json`
-
-* Edit `config.json`:
-
-```
-{
- "homeserver": "https://matrix.org",
- "username": "@name:matrix.org",
- "password": "my-secret-password", # Matrix password.
- "token": "my-secret-token", # Discord bot token.
- "discord_cmd_prefix": "my-command-prefix",
- "bridge": {
- "channel_id": "room_id",
- "channel_id2": "room_id2", # Bridge multiple rooms.
- },
-}
-```
-
-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.
-
-* Logs are saved to the `bridge.log` file in `$PWD`.
-
-* 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).
-
-**NOTE:** [Privileged Intents](https://discordpy.readthedocs.io/en/latest/intents.html#privileged-intents) must be enabled for your Discord bot.
diff --git a/bridge/bridge.py b/bridge/bridge.py
deleted file mode 100644
index 2b3c00b..0000000
--- a/bridge/bridge.py
+++ /dev/null
@@ -1,602 +0,0 @@
-import asyncio
-import json
-import logging
-import os
-import re
-import sys
-import uuid
-
-import aiofiles
-import aiofiles.os
-import aiohttp
-import discord
-import discord.ext.commands
-import nio
-
-
-def config_gen(config_file):
- config_dict = {
- "homeserver": "https://matrix.org",
- "username": "@name:matrix.org",
- "password": "my-secret-password",
- "token": "my-secret-token",
- "discord_cmd_prefix": "my-command-prefix",
- "bridge": {"channel_id": "room_id"},
- }
-
- if not os.path.exists(config_file):
- with open(config_file, "w") as f:
- json.dump(config_dict, f, indent=4)
- print(f"Example configuration dumped to {config_file}")
- sys.exit()
-
- with open(config_file, "r") as f:
- config = json.loads(f.read())
-
- return config
-
-
-config = config_gen("config.json")
-message_store = {}
-
-
-class MatrixClient(nio.AsyncClient):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- self.logger = logging.getLogger("matrix_logger")
-
- self.listen = False
- self.uploaded_emotes = {}
- self.ready = asyncio.Event()
- self.loop = asyncio.get_event_loop()
-
- self.start_discord()
- self.add_callbacks()
-
- def start_discord(self):
- # Intents to fetch members from guild.
- intents = discord.Intents.all()
- intents.members = True
-
- self.discord_client = DiscordClient(
- self,
- allowed_mentions=discord.AllowedMentions(
- everyone=False, roles=False
- ),
- command_prefix=config["discord_cmd_prefix"],
- intents=intents,
- )
-
- self.bg_task = self.loop.create_task(
- self.discord_client.start(config["token"])
- )
-
- def add_callbacks(self):
- callbacks = Callbacks(self.discord_client, self)
-
- self.add_event_callback(
- callbacks.message_callback,
- (nio.RoomMessageText, nio.RoomMessageMedia, nio.RoomMessageEmote),
- )
-
- self.add_event_callback(
- callbacks.redaction_callback, nio.RedactionEvent
- )
-
- self.add_ephemeral_callback(
- callbacks.typing_callback, nio.EphemeralEvent
- )
-
- async def upload_emote(self, emote_id):
- if emote_id in self.uploaded_emotes.keys():
- return self.uploaded_emotes[emote_id]
-
- emote_url = f"https://cdn.discordapp.com/emojis/{emote_id}"
-
- emote_file = f"/tmp/{str(uuid.uuid4())}"
-
- async with aiohttp.ClientSession() as session:
- async with session.get(emote_url) as resp:
- emote = await resp.read()
- content_type = resp.content_type
-
- async with aiofiles.open(emote_file, "wb") as f:
- await f.write(emote)
-
- async with aiofiles.open(emote_file, "rb") as f:
- resp, maybe_keys = await self.upload(f, content_type=content_type)
-
- await aiofiles.os.remove(emote_file)
-
- if type(resp) != nio.UploadResponse:
- self.logger.warning(f"Failed to upload emote {emote_id}")
- return
-
- self.uploaded_emotes[emote_id] = resp.content_uri
-
- return resp.content_uri
-
- async def get_fmt_body(self, body, emotes):
- replace_ = [
- # Bold.
- ("**", "", ""),
- # Code blocks.
- ("```", "
", "
"),
- # Spoilers.
- ("||", "", ""),
- # Strikethrough.
- ("~~", "", ""),
- ]
-
- for replace in replace_:
- for i in range(1, body.count(replace[0]) + 1):
- if i % 2:
- body = body.replace(replace[0], replace[1], 1)
- else:
- body = body.replace(replace[0], replace[2], 1)
-
- for emote in emotes.keys():
- emote_ = await self.upload_emote(emotes[emote])
- if emote_:
- emote = f":{emote}:"
- body = body.replace(
- emote,
- f"""""",
- )
-
- return body
-
- async def message_send(
- self, message, channel_id, emotes, reply_id=None, edit_id=None
- ):
- room_id = config["bridge"][str(channel_id)]
-
- content = {
- "body": message,
- "format": "org.matrix.custom.html",
- "formatted_body": await self.get_fmt_body(message, emotes),
- "msgtype": "m.text",
- }
-
- if reply_id:
- reply_event = await self.room_get_event(room_id, reply_id)
- reply_event = reply_event.event
-
- content = {
- **content,
- "m.relates_to": {"m.in_reply_to": {"event_id": reply_id}},
- "formatted_body": f"""\
-In reply to\
-{reply_event.sender}\
-
{reply_event.body}
{content["formatted_body"]}""",
- }
-
- if edit_id:
- content = {
- **content,
- "body": f" * {content['body']}",
- "formatted_body": f" * {content['formatted_body']}",
- "m.relates_to": {"event_id": edit_id, "rel_type": "m.replace"},
- "m.new_content": {**content},
- }
-
- message = await self.room_send(
- room_id=room_id, message_type="m.room.message", content=content
- )
-
- return message.event_id
-
- async def message_redact(self, message, channel_id):
- await self.room_redact(
- room_id=config["bridge"][str(channel_id)], event_id=message
- )
-
- async def webhook_send(
- self, author, avatar, message, event_id, channel_id, embed=None
- ):
- channel = self.discord_client.channel_store[channel_id]
-
- hook_name = "matrix_bridge"
-
- hook = self.discord_client.webhook_cache.get(str(channel.id))
-
- if not hook:
- hooks = await channel.webhooks()
- hook = discord.utils.get(hooks, name=hook_name)
-
- if not hook:
- hook = await channel.create_webhook(name=hook_name)
-
- self.discord_client.webhook_cache[str(channel.id)] = hook
-
- # Username must be between 1 and 80 characters in length,
- # 'wait=True' allows us to store the sent message.
- try:
- hook = await hook.send(
- username=author[:80],
- avatar_url=avatar,
- content=message,
- embed=embed,
- wait=True,
- )
-
- message_store[event_id] = hook
- message_store[hook.id] = event_id
- except discord.errors.HTTPException as e:
- self.logger.warning(f"Failed to send message {event_id}: {e}")
-
-
-class DiscordClient(discord.ext.commands.Bot):
- def __init__(self, matrix_client, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- self.channel_store = {}
-
- self.webhook_cache = {}
-
- self.ready = asyncio.Event()
-
- self.add_cogs()
-
- self.matrix_client = matrix_client
-
- def add_cogs(self):
- cogs_dir = "./cogs"
-
- if not os.path.isdir(cogs_dir):
- return
-
- for cog in os.listdir(cogs_dir):
- if cog.endswith(".py"):
- cog = f"cogs.{cog[:-3]}"
- self.load_extension(cog)
-
- async def to_return(self, channel_id, message=None):
- await self.matrix_client.ready.wait()
-
- return str(channel_id) not in config["bridge"].keys() or (
- message
- and message.webhook_id
- in [hook.id for hook in self.webhook_cache.values()]
- )
-
- async def on_ready(self):
- for channel in config["bridge"].keys():
- channel_ = self.get_channel(int(channel))
- if not channel_:
- self.matrix_client.logger.warning(f"Failed to get channel for ID {channel}")
- continue
- self.channel_store[channel] = channel_
-
- self.ready.set()
-
- async def on_message(self, message):
- # Process other stuff like cogs before ignoring the message.
- await self.process_commands(message)
-
- if await self.to_return(message.channel.id, message):
- return
-
- content = await self.process_message(message)
-
- matrix_message = await self.matrix_client.message_send(
- content[0],
- message.channel.id,
- reply_id=content[1],
- emotes=content[2],
- )
-
- message_store[message.id] = matrix_message
-
- async def on_message_edit(self, before, after):
- if await self.to_return(after.channel.id, after):
- return
-
- content = await self.process_message(after)
-
- # Edit message only if it can be looked up in the cache.
- if before.id in message_store:
- await self.matrix_client.message_send(
- content[0],
- after.channel.id,
- edit_id=message_store[before.id],
- emotes=content[2],
- )
-
- async def on_message_delete(self, message):
- # Delete message only if it can be looked up in the cache.
- if message.id in message_store:
- await self.matrix_client.message_redact(
- message_store[message.id], message.channel.id
- )
-
- async def on_typing(self, channel, user, when):
- if await self.to_return(channel.id) or user == self.user:
- return
-
- # Send typing event
- await self.matrix_client.room_typing(
- config["bridge"][str(channel.id)], timeout=0
- )
-
- async def process_message(self, message):
- content = message.clean_content
-
- regex = r""
- emotes = {}
-
- # Store all emotes in a dict to upload and insert into formatted body.
- # { "emote_name": "emote_id" }
- for emote in re.findall(regex, content):
- emotes[emote[0]] = emote[1]
-
- # Get message reference for replies.
- replied_event = None
- if message.reference:
- replied_message = await message.channel.fetch_message(
- message.reference.message_id
- )
- # Try to get the corresponding event from the message cache.
- try:
- replied_event = message_store[replied_message.id]
- except KeyError:
- pass
-
- # Replace emote IDs with names.
- content = re.sub(regex, r":\g<1>:", content)
-
- # Append attachments to message.
- for attachment in message.attachments:
- content += f"\n{attachment.url}"
-
- content = f"[{message.author.display_name}] {content}"
-
- return content, replied_event, emotes
-
-
-class Callbacks(object):
- def __init__(self, discord_client, matrix_client):
- self.discord_client = discord_client
- self.matrix_client = matrix_client
-
- def get_channel(self, room):
- channel_id = next(
- (
- channel_id
- for channel_id, room_id in config["bridge"].items()
- if room_id == room.room_id
- ),
- None,
- )
-
- return channel_id
-
- async def to_return(self, room, event):
- await self.matrix_client.discord_client.ready.wait()
-
- return (
- room.room_id not in config["bridge"].values()
- or event.sender == self.matrix_client.user
- or not self.matrix_client.listen
- )
-
- async def message_callback(self, room, event):
- message = event.body
-
- # Ignore messages having an empty body.
- if await self.to_return(room, event) or not message:
- return
-
- content_dict = event.source.get("content")
-
- # Get the corresponding Discord channel.
- channel_id = self.get_channel(room)
-
- if not channel_id:
- return
-
- author = room.user_name(event.sender)
- avatar = None
-
- homeserver = event.sender.split(":")[-1]
- url = "https://matrix.org/_matrix/media/r0/download"
-
- try:
- if content_dict["m.relates_to"]["rel_type"] == "m.replace":
- # Get the original message's event ID.
- edited_event = content_dict["m.relates_to"]["event_id"]
- edited_content = await self.process_message(
- content_dict["m.new_content"]["body"], channel_id
- )
-
- # Get the corresponding Discord message.
- webhook_message = message_store[edited_event]
-
- try:
- await webhook_message.edit(content=edited_content)
- # Handle exception if edited message was deleted on Discord.
- except (
- discord.errors.NotFound,
- discord.errors.HTTPException,
- ) as e:
- self.matrix_client.logger.warning(
- f"Failed to edit message {edited_event}: {e}"
- )
-
- return
- except KeyError:
- pass
-
- try:
- if (
- content_dict["m.relates_to"]["m.in_reply_to"]["event_id"]
- in message_store.values()
- ):
- # Remove the first occurence of our bot's username if replying.
- # > <@discordbridge:something.org> [discord user]
- message = message.replace(f"<{config['username']}>", "", 1)
- except KeyError:
- pass
-
- # _testuser waves_ (Italics)
- if content_dict["msgtype"] == "m.emote":
- message = f"_{author} {message}_"
-
- message = await self.process_message(message, channel_id)
-
- embed = None
-
- # Get attachments.
- try:
- attachment = event.url.split("/")[-1]
- # TODO: Fix URL for attachments forwarded from other rooms.
- attachment = f"{url}/{homeserver}/{attachment}"
-
- embed = discord.Embed(colour=discord.Colour.blue(), title=message)
- embed.set_image(url=attachment)
-
- # Send attachment URL in message along with embed,
- # Just in-case the attachment is not an image.
- message = attachment
- except AttributeError:
- pass
-
- # Get avatar.
- for user in room.users.values():
- if user.user_id == event.sender:
- if user.avatar_url:
- avatar = user.avatar_url.split("/")[-1]
- avatar = f"{url}/{homeserver}/{avatar}"
- break
-
- await self.matrix_client.webhook_send(
- author, avatar, message, event.event_id, channel_id, embed=embed
- )
-
- async def redaction_callback(self, room, event):
- if await self.to_return(room, event):
- return
-
- # Try to fetch the message from cache.
- try:
- message = message_store[event.redacts]
- await message.delete()
- # Handle exception if message was already deleted on Discord.
- except discord.errors.NotFound as e:
- self.matrix_client.logger.warning(
- f"Failed to delete message {event.event_id}: {e}"
- )
- except KeyError:
- pass
-
- async def typing_callback(self, room, event):
- if (
- not room.typing_users
- or (
- len(room.typing_users) == 1
- and self.matrix_client.user in room.typing_users
- )
- or room.room_id not in config["bridge"].values()
- ):
- return
-
- # Get the corresponding Discord channel.
- channel_id = self.get_channel(room)
-
- if not channel_id:
- return
-
- # Send typing event.
- async with self.discord_client.channel_store[channel_id].typing():
- return
-
- async def process_message(self, message, channel_id):
- emotes = re.findall(r":(\w*):", message)
- mentions = re.findall(r"(@(\w*))", message)
-
- # Get the guild from channel ID.
- guild = self.discord_client.channel_store[channel_id].guild
-
- added_emotes = []
- for emote in emotes:
- # Don't replace emote names with IDs multiple times.
- # :emote: becomes <:emote:emote_id>
- if emote not in added_emotes:
- added_emotes.append(emote)
- emote_ = discord.utils.get(guild.emojis, name=emote)
- if emote_:
- message = message.replace(f":{emote}:", str(emote_))
-
- # mentions = [('@name', 'name'), ('@', '')]
- for mention in mentions:
- # Don't fetch member if mention is empty.
- # Single "@" without any name.
- if mention[1]:
- member = await guild.query_members(query=mention[1])
- if member:
- # Get first result.
- message = message.replace(mention[0], member[0].mention)
-
- return message
-
-
-async def main():
- logging.basicConfig(
- level=logging.INFO,
- format="%(asctime)s %(name)s:%(levelname)s: %(message)s",
- datefmt="%Y-%m-%d %H:%M:%S",
- handlers=[
- logging.FileHandler("bridge.log"),
- logging.StreamHandler(),
- ],
- )
-
- retry = 2
-
- matrix_client = MatrixClient(config["homeserver"], config["username"])
-
- while True:
- resp = await matrix_client.login(config["password"])
-
- if type(resp) == nio.LoginError:
- matrix_client.logger.error(f"Failed to login: {resp}")
- return False
-
- # Login successful.
- matrix_client.logger.info(resp)
-
- try:
- await matrix_client.sync(full_state=True)
- except Exception:
- matrix_client.logger.exception("Initial sync failed!")
- return False
-
- try:
- matrix_client.ready.set()
- matrix_client.listen = True
-
- matrix_client.logger.info("Clients ready!")
-
- await matrix_client.sync_forever(timeout=30000, full_state=True)
- except Exception:
- matrix_client.logger.exception(
- f"Unknown exception occured, retrying in {retry} seconds..."
- )
-
- # Clear "ready" status.
- matrix_client.ready.clear()
-
- await matrix_client.close()
- await asyncio.sleep(retry)
-
- matrix_client.listen = False
- finally:
- if matrix_client.listen:
- await matrix_client.close()
- return False
-
-
-if __name__ == "__main__":
- asyncio.run(main())
diff --git a/bridge/cogs/.keep b/bridge/cogs/.keep
deleted file mode 100644
index e69de29..0000000
diff --git a/bridge/requirements.txt b/bridge/requirements.txt
deleted file mode 100644
index 16946d4..0000000
--- a/bridge/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-discord.py==2.0.1
-matrix-nio==0.19.0
diff --git a/demo.png b/demo.png
deleted file mode 100644
index 5440f16..0000000
Binary files a/demo.png and /dev/null differ
diff --git a/misc/migrate_emotes.py b/misc/migrate_emotes.py
deleted file mode 100644
index 2f62c2d..0000000
--- a/misc/migrate_emotes.py
+++ /dev/null
@@ -1,160 +0,0 @@
-import asyncio
-import json
-import logging
-import os
-import sys
-import uuid
-import aiofiles
-import aiofiles.os
-import aiohttp
-import discord
-import nio
-
-
-def config_gen(config_file):
- config_dict = {
- "homeserver": "https://matrix.org",
- "username": "@name:matrix.org",
- "password": "my-secret-password",
- "token": "my-secret-token",
- "migrate": {"guild_id": "room_id"},
- }
-
- if not os.path.exists(config_file):
- with open(config_file, "w") as f:
- json.dump(config_dict, f, indent=4)
- print(f"Example configuration dumped to {config_file}")
- sys.exit()
-
- with open(config_file, "r") as f:
- config = json.loads(f.read())
-
- return config
-
-
-config = config_gen("config.json")
-
-
-class MatrixClient(nio.AsyncClient):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- self.logger = logging.getLogger("matrix_logger")
- self.uploaded_emotes = {}
-
- async def start(self, discord_client):
- timeout = 30000
-
- self.logger.info(await self.login(config["password"]))
-
- self.logger.info("Syncing...")
- await self.sync(timeout)
-
- await discord_client.wait_until_ready()
- await discord_client.migrate()
-
- async def upload_emote(self, emote):
- emote_name = f":{emote.name}:"
- emote_file = f"/tmp/{str(uuid.uuid4())}"
-
- async with aiohttp.ClientSession() as session:
- async with session.get(str(emote.url)) as resp:
- emote_ = await resp.read()
- content_type = resp.content_type
-
- async with aiofiles.open(emote_file, "wb") as f:
- await f.write(emote_)
-
- async with aiofiles.open(emote_file, "rb") as f:
- resp, maybe_keys = await self.upload(f, content_type=content_type)
-
- await aiofiles.os.remove(emote_file)
-
- if type(resp) != nio.UploadResponse:
- self.logger.warning(f"Failed to upload {emote_name}")
- return
-
- self.logger.info(f"Uploaded {emote_name}")
-
- url = resp.content_uri
-
- self.uploaded_emotes[emote_name] = {}
- self.uploaded_emotes[emote_name]["url"] = url
-
- async def send_emote_state(self, room_id, emote_dict):
- event_type = "im.ponies.room_emotes"
-
- emotes = {}
-
- emotes_ = await self.room_get_state_event(room_id, event_type)
-
- # Get previous emotes from room
- if type(emotes_) != nio.RoomGetStateEventError:
- emotes = emotes_.content.get("emoticons")
-
- content = {"emoticons": {**emotes, **emote_dict}}
-
- resp = await self.room_put_state(room_id, event_type, content)
-
- if type(resp) == nio.RoomPutStateError:
- self.logger.warning(f"Failed to send emote state: {resp}")
-
-
-class DiscordClient(discord.Client):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- self.matrix_client = MatrixClient(
- config["homeserver"], config["username"]
- )
-
- self.bg_task = self.loop.create_task(
- self.log_exceptions(self.matrix_client)
- )
-
- self.logger = logging.getLogger("discord_logger")
-
- async def log_exceptions(self, matrix_client):
- try:
- return await matrix_client.start(self)
- except Exception as e:
- matrix_client.logger.warning(f"Unknown exception occurred: {e}")
-
- await matrix_client.close()
-
- async def migrate(self):
- for guild in config["migrate"].keys():
- emote_guild = self.get_guild(int(guild))
- emote_room = config["migrate"][guild]
-
- if emote_guild:
- self.logger.info(
- f"Guild: {emote_guild.name} Room: {emote_room}"
- )
-
- await asyncio.gather(
- *map(self.matrix_client.upload_emote, emote_guild.emojis)
- )
-
- self.logger.info("Sending state event to room...")
-
- await self.matrix_client.send_emote_state(
- emote_room, self.matrix_client.uploaded_emotes
- )
-
- self.logger.info("Finished uploading emotes")
-
- await self.matrix_client.logout()
- await self.matrix_client.close()
-
- await self.close()
-
-
-def main():
- logging.basicConfig(level=logging.INFO)
-
- DiscordClient().run(config["token"])
-
-
-if __name__ == "__main__":
- main()
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..be79a97
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,30 @@
+[build-system]
+requires = ["setuptools", "setuptools-scm"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "aioappsrv"
+authors = [
+ {name = "alemi", email = "me@alemi.dev"},
+ {name = "git-bruh", email = ""},
+]
+description = "simple and asynchronous matrix appservice framework"
+readme = "README.md"
+requires-python = ">=3.7"
+keywords = ["matrix", "appservice", "bot", "bridge"]
+dynamic = ["version"]
+license = {file = "LICENSE"}
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+]
+dependencies = [
+ "bottle",
+ "markdown",
+ "urllib3",
+ "websockets",
+]
+
+[tool.setuptools_scm]
+write_to = "src/aioappsrv/__version__.py"
diff --git a/appservice/appservice.py b/src/aioappsrv/appservice.py
similarity index 100%
rename from appservice/appservice.py
rename to src/aioappsrv/appservice.py
diff --git a/appservice/cache.py b/src/aioappsrv/cache.py
similarity index 100%
rename from appservice/cache.py
rename to src/aioappsrv/cache.py
diff --git a/appservice/db.py b/src/aioappsrv/db.py
similarity index 100%
rename from appservice/db.py
rename to src/aioappsrv/db.py
diff --git a/appservice/discord.py b/src/aioappsrv/discord.py
similarity index 100%
rename from appservice/discord.py
rename to src/aioappsrv/discord.py
diff --git a/appservice/errors.py b/src/aioappsrv/errors.py
similarity index 100%
rename from appservice/errors.py
rename to src/aioappsrv/errors.py
diff --git a/appservice/gateway.py b/src/aioappsrv/gateway.py
similarity index 100%
rename from appservice/gateway.py
rename to src/aioappsrv/gateway.py
diff --git a/appservice/main.py b/src/aioappsrv/main.py
similarity index 100%
rename from appservice/main.py
rename to src/aioappsrv/main.py
diff --git a/appservice/matrix.py b/src/aioappsrv/matrix.py
similarity index 100%
rename from appservice/matrix.py
rename to src/aioappsrv/matrix.py
diff --git a/appservice/misc.py b/src/aioappsrv/misc.py
similarity index 100%
rename from appservice/misc.py
rename to src/aioappsrv/misc.py