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.)
|
`user_id`: The username of the appservice user, it should match the `sender_localpart` in `appservice.yaml`.
|
||||||
- [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`)
|
|
||||||
|
|
||||||
## 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).
|
`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`.
|
||||||
- [ ] Use embeds on Discord side for replies.
|
|
||||||
- [ ] Unbridging.
|
`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