chore: restructured and packaged
This commit is contained in:
parent
924666ab1d
commit
461127a4ac
21 changed files with 273 additions and 931 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal 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
132
.gitignore
vendored
Normal 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
122
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.
|
||||
|
|
|
@ -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.
|
|
@ -1,4 +0,0 @@
|
|||
bottle
|
||||
markdown
|
||||
urllib3
|
||||
websockets
|
|
@ -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.
|
602
bridge/bridge.py
602
bridge/bridge.py
|
@ -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())
|
|
@ -1,2 +0,0 @@
|
|||
discord.py==2.0.1
|
||||
matrix-nio==0.19.0
|
BIN
demo.png
BIN
demo.png
Binary file not shown.
Before Width: | Height: | Size: 161 KiB |
|
@ -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
30
pyproject.toml
Normal 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"
|
Loading…
Reference in a new issue