chore: restructured and packaged

This commit is contained in:
əlemi 2024-01-29 03:27:13 +01:00
parent 924666ab1d
commit 461127a4ac
Signed by: alemi
GPG key ID: A4895B84D311642C
21 changed files with 273 additions and 931 deletions

10
.editorconfig Normal file
View file

@ -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

132
.gitignore vendored Normal file
View file

@ -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

122
README.md
View file

@ -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.

View file

@ -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.

View file

@ -1,4 +0,0 @@
bottle
markdown
urllib3
websockets

View file

@ -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.

View file

@ -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.
("**", "<strong>", "</strong>"),
# Code blocks.
("```", "<pre><code>", "</code></pre>"),
# Spoilers.
("||", "<span data-mx-spoiler>", "</span>"),
# Strikethrough.
("~~", "<del>", "</del>"),
]
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"""<img alt=\"{emote}\" title=\"{emote}\" \
height=\"32\" src=\"{emote_}\" data-mx-emoticon />""",
)
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"""<mx-reply><blockquote>\
<a href="https://matrix.to/#/{room_id}/{reply_id}">In reply to</a>\
<a href="https://matrix.to/#/{reply_event.sender}">{reply_event.sender}</a>\
<br>{reply_event.body}</blockquote></mx-reply>{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"<a?:(\w+):(\d+)>"
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())

View file

View file

@ -1,2 +0,0 @@
discord.py==2.0.1
matrix-nio==0.19.0

BIN
demo.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

View file

@ -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()

30
pyproject.toml Normal file
View file

@ -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 = "<e817509a-8ee9-4332-b0ad-3a6bdf9ab63f@aleeas.com>"},
]
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"